feat(admin-sdk,admin-bundler,admin-shared,medusa): Restructure admin packages (#8988)

**What**
- Renames /admin-next -> /admin
- Renames @medusajs/admin-sdk -> @medusajs/admin-bundler
- Creates a new package called @medusajs/admin-sdk that will hold all tooling relevant to creating admin extensions. This is currently `defineRouteConfig` and `defineWidgetConfig`, but will eventually also export methods for adding custom fields, register translation, etc. 
  - cc: @shahednasser we should update the examples in the docs so these functions are imported from `@medusajs/admin-sdk`. People will also need to install the package in their project, as it's no longer a transient dependency.
  - cc: @olivermrbl we might want to publish a changelog when this is merged, as it is a breaking change, and will require people to import the `defineXConfig` from the new package instead of `@medusajs/admin-shared`.
- Updates CODEOWNERS so /admin packages does not require a review from the UI team.
This commit is contained in:
Kasper Fabricius Kristensen
2024-09-04 21:00:25 +02:00
committed by GitHub
parent beaa851302
commit 0fe1201435
1440 changed files with 122 additions and 86 deletions

View File

@@ -0,0 +1 @@
# @medusajs/admin-vite-plugin

View File

@@ -0,0 +1,47 @@
{
"name": "@medusajs/admin-vite-plugin",
"version": "0.0.1",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/admin/admin-vite-plugin"
},
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "tsup",
"watch": "tsup --watch"
},
"devDependencies": {
"@babel/types": "7.22.5",
"@types/babel__traverse": "7.20.5",
"@types/node": "^20.10.4",
"tsup": "8.0.1",
"typescript": "5.3.3",
"vite": "^5.2.11"
},
"peerDependencies": {
"vite": "^5.0.0"
},
"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"
},
"packageManager": "yarn@3.2.1"
}

View File

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

View File

@@ -0,0 +1,4 @@
import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin"
export default medusaVitePlugin
export type { MedusaVitePlugin }

View File

@@ -0,0 +1,830 @@
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"]
function convertToImportPath(file: string) {
return path.normalize(file).split(path.sep).join("/")
}
/**
* Returns the module type of a given file.
*/
function getModuleType(file: string) {
const normalizedPath = convertToImportPath(file)
if (normalizedPath.includes("/admin/widgets/")) {
return "widget"
} else if (normalizedPath.includes("/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<ExportNamedDeclaration>) {
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<ExportDefaultDeclaration>,
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<ExportNamedDeclaration>,
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<File>
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<string>,
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 "${convertToImportPath(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<ExportNamedDeclaration>,
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<File>
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) {
const importPath = convertToImportPath(file)
return importPath
.replace(/.*\/admin\/(routes|settings)/, "")
.replace(/\[([^\]]+)\]/g, ":$1")
.replace(/\/page\.(tsx|jsx)/, "")
}
async function generateRouteEntrypoint(
sources: Set<string>,
type: "page" | "link"
) {
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 "${convertToImportPath(path)}";`
: `import { config as routeConfig${index} } from "${convertToImportPath(
path
)}";`
})
.join("\n")
const exportString = `export default {
${type}s: [${validatedRoutes
.map((file, index) => {
return type === "page"
? `{ path: "${createRoutePath(file)}", Component: RouteExt${index} }`
: `{ 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<string, Set<string>>()
const _sources = new Set<string>(options?.sources ?? [])
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)
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<string>()
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<string>()
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 (_extensionGraph.has(file)) {
const modules = _extensionGraph.get(file)
if (!modules) {
return
}
for (const moduleId of modules) {
const module = server?.moduleGraph.getModuleById(moduleId)
if (!module || !module.id) {
continue
}
const matchedInjectionZone = getWidgetZone(module.id)
/**
* If the widget is imported in a module that does not match the new
* zone value, we need to reload the module, so the widget will be removed.
*/
if (!zoneValues.includes(matchedInjectionZone)) {
modules.delete(moduleId)
await server?.reloadModule(module)
}
}
const imports = new Set<string>(modules)
/**
* If the widget is not currently being imported by the virtual module that
* matches its zone value, we need to reload the module, so the widget will be added.
*/
for (const zoneValue of zoneValues) {
const zonePath = getWidgetImport(zoneValue)
const moduleId = getVirtualId(zonePath)
const resolvedModuleId = resolveVirtualId(moduleId)
if (!modules.has(resolvedModuleId)) {
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<string>()
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 imports = new Set<string>()
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)
}
}
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<string>()
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",
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()
}
},
}
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"skipLibCheck": true,
"isolatedModules": true,
"strict": true,
"declarationMap": true,
"declaration": true,
"sourceMap": true,
"noEmit": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@babel/types": ["../../../node_modules/@babel/types"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["./src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
})