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
This commit is contained in:
committed by
GitHub
parent
521c252dee
commit
f1176a0673
32
packages/admin-next/admin-vite-plugin/src/babel.ts
Normal file
32
packages/admin-next/admin-vite-plugin/src/babel.ts
Normal 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,
|
||||
}
|
||||
@@ -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<string, Set<string>>()
|
||||
const _sources = new Set<string>([...(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<ExportDefaultDeclaration>,
|
||||
ast: ParseResult<File>
|
||||
) {
|
||||
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<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 === "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<ExportNamedDeclaration>,
|
||||
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<File>
|
||||
|
||||
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<ExportNamedDeclaration>,
|
||||
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<File>
|
||||
|
||||
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<File>
|
||||
|
||||
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<ExportNamedDeclaration>) {
|
||||
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<string>()
|
||||
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<string, LoadModuleOptions> = {
|
||||
[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 }
|
||||
|
||||
794
packages/admin-next/admin-vite-plugin/src/plugin.ts
Normal file
794
packages/admin-next/admin-vite-plugin/src/plugin.ts
Normal file
@@ -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<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 "${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) {
|
||||
return file
|
||||
.replace(/.*\/admin\/(routes|settings)/, "")
|
||||
.replace(/\[([^\]]+)\]/g, ":$1")
|
||||
.replace(/\/page\.(tsx|jsx)/, "")
|
||||
}
|
||||
|
||||
async function generateRouteEntrypoint(
|
||||
sources: Set<string>,
|
||||
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<string, Set<string>>()
|
||||
const _sources = new Set<string>(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<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 (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 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<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",
|
||||
configResolved(config) {
|
||||
if (config.server?.middlewareMode) {
|
||||
/**
|
||||
* If we are in middleware mode, we need to set the base to the <base> + "@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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user