feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761)
Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
26c78bbc03
commit
f1a05f4725
@@ -0,0 +1,58 @@
|
||||
import path from "path"
|
||||
import { normalizePath } from "../normalize-path"
|
||||
|
||||
describe("normalize path", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("should normalize a file path", async () => {
|
||||
const testPath = path.join("/", "custom", "page.tsx")
|
||||
|
||||
const result = normalizePath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/page.tsx")
|
||||
})
|
||||
|
||||
it("should normalize a file path with brackets", async () => {
|
||||
const testPath = path.join("/", "custom", "[id]", "page.tsx")
|
||||
|
||||
const result = normalizePath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/[id]/page.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("test windows platform", () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
})
|
||||
})
|
||||
|
||||
it("should normalize a file path on Windows", async () => {
|
||||
const testPath = path.win32.join("/", "custom", "page.tsx")
|
||||
|
||||
const result = normalizePath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/page.tsx")
|
||||
})
|
||||
|
||||
it("should normalize a file path with brackets on Windows", async () => {
|
||||
const testPath = path.win32.join("/", "custom", "[id]", "page.tsx")
|
||||
|
||||
const result = normalizePath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/[id]/page.tsx")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import path from "path"
|
||||
import { createPath } from "../validate-extensions"
|
||||
|
||||
describe("validate extensions", () => {
|
||||
beforeEach(function () {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("createPath", () => {
|
||||
it("should return a URL path", async () => {
|
||||
const testPath = path.join("/", "custom", "page.tsx")
|
||||
|
||||
const result = createPath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom")
|
||||
})
|
||||
|
||||
it("should return a URL path with a parameter", async () => {
|
||||
const testPath = path.join("/", "custom", "[id]", "page.tsx")
|
||||
|
||||
const result = createPath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/:id")
|
||||
})
|
||||
})
|
||||
|
||||
describe("test windows platform", () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
})
|
||||
})
|
||||
|
||||
describe("createPath", () => {
|
||||
it("should return a URL path on Windows", async () => {
|
||||
const testPath = path.win32.join("/", "custom", "page.tsx")
|
||||
|
||||
const result = createPath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom")
|
||||
})
|
||||
|
||||
it("should return a URL path with a parameter on Windows", async () => {
|
||||
const testPath = path.win32.join("/", "custom", "[id]", "page.tsx")
|
||||
|
||||
const result = createPath(testPath)
|
||||
|
||||
expect(result).toEqual("/custom/:id")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
22
packages/admin-ui/src/node/utils/copy-filter.ts
Normal file
22
packages/admin-ui/src/node/utils/copy-filter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import fse from "fs-extra"
|
||||
|
||||
/**
|
||||
* Filter function to exclude test files and folders, as well as webpack configurations from being copied to the cache folder.
|
||||
*/
|
||||
export function copyFilter(src: string) {
|
||||
if (fse.lstatSync(src).isDirectory() && src.includes("__test__")) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fse.lstatSync(src).isFile()) {
|
||||
if (
|
||||
src.includes(".test") ||
|
||||
src.includes(".spec") ||
|
||||
src.includes("webpack.config")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
46
packages/admin-ui/src/node/utils/create-cache-dir.ts
Normal file
46
packages/admin-ui/src/node/utils/create-cache-dir.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fse from "fs-extra"
|
||||
import path from "node:path"
|
||||
import { copyFilter } from "./copy-filter"
|
||||
import { createEntry } from "./create-entry"
|
||||
import { logger } from "./logger"
|
||||
|
||||
async function copyAdmin(dest: string) {
|
||||
const adminDir = path.resolve(__dirname, "..", "ui")
|
||||
const destDir = path.resolve(dest, "admin")
|
||||
|
||||
try {
|
||||
await fse.copy(adminDir, destDir, {
|
||||
filter: copyFilter,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.panic(
|
||||
`Could not copy the admin UI to ${destDir}. See the error below for details:`,
|
||||
{
|
||||
error: err,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateCacheDirArgs = {
|
||||
appDir: string
|
||||
plugins?: string[]
|
||||
}
|
||||
|
||||
async function createCacheDir({ appDir, plugins }: CreateCacheDirArgs) {
|
||||
const cacheDir = path.resolve(appDir, ".cache")
|
||||
|
||||
await copyAdmin(cacheDir)
|
||||
|
||||
await createEntry({
|
||||
appDir,
|
||||
dest: cacheDir,
|
||||
plugins,
|
||||
})
|
||||
|
||||
return {
|
||||
cacheDir,
|
||||
}
|
||||
}
|
||||
|
||||
export { createCacheDir }
|
||||
321
packages/admin-ui/src/node/utils/create-entry.ts
Normal file
321
packages/admin-ui/src/node/utils/create-entry.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import fse from "fs-extra"
|
||||
import path from "node:path"
|
||||
import dedent from "ts-dedent"
|
||||
import { copyFilter } from "./copy-filter"
|
||||
import { logger } from "./logger"
|
||||
import { normalizePath } from "./normalize-path"
|
||||
import {
|
||||
findAllValidRoutes,
|
||||
findAllValidSettings,
|
||||
findAllValidWidgets,
|
||||
} from "./validate-extensions"
|
||||
|
||||
const FILE_EXT_REGEX = /\.[^/.]+$/
|
||||
|
||||
async function copyLocalExtensions(src: string, dest: string) {
|
||||
try {
|
||||
await fse.copy(src, dest, {
|
||||
filter: copyFilter,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Could not copy local extensions to cache folder. See the error below for details:`,
|
||||
{
|
||||
error: err,
|
||||
}
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an entry file for any local extensions, if they exist.
|
||||
*/
|
||||
async function createLocalExtensionsEntry(appDir: string, dest: string) {
|
||||
const localAdminDir = path.resolve(appDir, "src", "admin")
|
||||
|
||||
const localAdminDirExists = await fse.pathExists(localAdminDir)
|
||||
|
||||
if (!localAdminDirExists) {
|
||||
return false
|
||||
}
|
||||
|
||||
const copied = await copyLocalExtensions(
|
||||
localAdminDir,
|
||||
path.resolve(dest, "admin", "src", "extensions")
|
||||
)
|
||||
|
||||
if (!copied) {
|
||||
logger.error(
|
||||
"Could not copy local extensions to cache folder. See above error for details. The error must be fixed before any local extensions can be injected."
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const [localWidgets, localRoutes, localSettings] = await Promise.all([
|
||||
findAllValidWidgets(
|
||||
path.resolve(dest, "admin", "src", "extensions", "widgets")
|
||||
),
|
||||
findAllValidRoutes(
|
||||
path.resolve(dest, "admin", "src", "extensions", "routes")
|
||||
),
|
||||
findAllValidSettings(
|
||||
path.resolve(dest, "admin", "src", "extensions", "settings")
|
||||
),
|
||||
])
|
||||
|
||||
const widgetsArray = localWidgets.map((file, index) => {
|
||||
const relativePath = normalizePath(
|
||||
path
|
||||
.relative(path.resolve(dest, "admin", "src", "extensions"), file)
|
||||
.replace(FILE_EXT_REGEX, "")
|
||||
)
|
||||
|
||||
return {
|
||||
importStatement: `import Widget${index}, { config as widgetConfig${index} } from "./${relativePath}"`,
|
||||
extension: `{ Component: Widget${index}, config: { ...widgetConfig${index}, type: "widget" } }`,
|
||||
}
|
||||
})
|
||||
|
||||
const routesArray = localRoutes.map((route, index) => {
|
||||
const relativePath = normalizePath(
|
||||
path
|
||||
.relative(path.resolve(dest, "admin", "src", "extensions"), route.file)
|
||||
.replace(FILE_EXT_REGEX, "")
|
||||
)
|
||||
|
||||
const importStatement = route.hasConfig
|
||||
? `import Page${index}, { config as routeConfig${index} } from "./${relativePath}"`
|
||||
: `import Page${index} from "./${relativePath}"`
|
||||
|
||||
const extension = route.hasConfig
|
||||
? `{ Component: Page${index}, config: { ...routeConfig${index}, type: "route", path: "${route.path}" } }`
|
||||
: `{ Component: Page${index}, config: { path: "${route.path}", type: "route" } }`
|
||||
|
||||
return {
|
||||
importStatement,
|
||||
extension,
|
||||
}
|
||||
})
|
||||
|
||||
const settingsArray = localSettings.map((setting, index) => {
|
||||
const relativePath = normalizePath(
|
||||
path
|
||||
.relative(
|
||||
path.resolve(dest, "admin", "src", "extensions"),
|
||||
setting.file
|
||||
)
|
||||
.replace(FILE_EXT_REGEX, "")
|
||||
)
|
||||
|
||||
return {
|
||||
importStatement: `import Setting${index}, { config as settingConfig${index} } from "./${relativePath}"`,
|
||||
extension: `{ Component: Setting${index}, config: { ...settingConfig${index}, path: "${setting.path}", type: "setting" } }`,
|
||||
}
|
||||
})
|
||||
|
||||
const extensionsArray = [...widgetsArray, ...routesArray, ...settingsArray]
|
||||
|
||||
const extensionsEntry = dedent`
|
||||
${extensionsArray.map((extension) => extension.importStatement).join("\n")}
|
||||
|
||||
const LocalEntry = {
|
||||
identifier: "local",
|
||||
extensions: [
|
||||
${extensionsArray.map((extension) => extension.extension).join(",\n")}
|
||||
],
|
||||
}
|
||||
|
||||
export default LocalEntry
|
||||
`
|
||||
|
||||
try {
|
||||
await fse.outputFile(
|
||||
path.resolve(dest, "admin", "src", "extensions", "_local-entry.ts"),
|
||||
extensionsEntry
|
||||
)
|
||||
} catch (err) {
|
||||
logger.panic(
|
||||
`Failed to write the entry file for the local extensions. See the error below for details:`,
|
||||
{
|
||||
error: err,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function findPluginsWithExtensions(plugins: string[]) {
|
||||
const pluginsWithExtensions: string[] = []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
const pluginDir = path.dirname(
|
||||
require.resolve(`${plugin}/package.json`, {
|
||||
paths: [process.cwd()],
|
||||
})
|
||||
)
|
||||
const entrypoint = path.resolve(
|
||||
pluginDir,
|
||||
"dist",
|
||||
"admin",
|
||||
"_virtual_entry.js"
|
||||
)
|
||||
|
||||
if (fse.existsSync(entrypoint)) {
|
||||
pluginsWithExtensions.push(entrypoint)
|
||||
}
|
||||
} catch (_err) {
|
||||
logger.warn(
|
||||
`There was an error while attempting to load extensions from the plugin: ${plugin}. Are you sure it is installed?`
|
||||
)
|
||||
// no plugin found - noop
|
||||
}
|
||||
}
|
||||
|
||||
return pluginsWithExtensions
|
||||
}
|
||||
|
||||
async function writeTailwindContentFile(dest: string, plugins: string[]) {
|
||||
const tailwindContent = dedent`
|
||||
const path = require("path")
|
||||
|
||||
const devPath = path.join(__dirname, "..", "..", "src/admin/**/*.{js,jsx,ts,tsx}")
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
devPath,
|
||||
${plugins
|
||||
.map((plugin) => {
|
||||
const tailwindContentPath = normalizePath(
|
||||
path.relative(
|
||||
path.resolve(dest, "admin"),
|
||||
path.dirname(path.join(plugin, "..", ".."))
|
||||
)
|
||||
)
|
||||
|
||||
return `"${tailwindContentPath}/dist/admin/**/*.{js,jsx,ts,tsx}"`
|
||||
})
|
||||
.join(",\n")}
|
||||
],
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
try {
|
||||
await fse.outputFile(
|
||||
path.resolve(dest, "admin", "tailwind.content.js"),
|
||||
tailwindContent
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Failed to write the Tailwind content file to ${dest}. The admin UI will remain functional, but CSS classes applied to extensions from plugins might not have the correct styles`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function createMainExtensionsEntry(
|
||||
dest: string,
|
||||
plugins: string[],
|
||||
hasLocalExtensions: boolean
|
||||
) {
|
||||
if (!plugins.length && !hasLocalExtensions) {
|
||||
// We still want to generate the entry file, even if there are no extensions
|
||||
// to load, so that the admin UI can be built without errors
|
||||
const emptyEntry = dedent`
|
||||
const extensions = []
|
||||
|
||||
export default extensions
|
||||
`
|
||||
|
||||
try {
|
||||
await fse.outputFile(
|
||||
path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"),
|
||||
emptyEntry
|
||||
)
|
||||
} catch (err) {
|
||||
logger.panic(
|
||||
`Failed to write the entry file for the main extensions. See the error below for details:`,
|
||||
{
|
||||
error: err,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const pluginsArray = plugins.map((plugin) => {
|
||||
const relativePath = normalizePath(
|
||||
path
|
||||
.relative(path.resolve(dest, "admin", "src", "extensions"), plugin)
|
||||
.replace(FILE_EXT_REGEX, "")
|
||||
)
|
||||
|
||||
return relativePath
|
||||
})
|
||||
|
||||
const extensionsArray = [
|
||||
...pluginsArray.map((plugin, index) => {
|
||||
const importStatement = `import Plugin${index} from "${plugin}"`
|
||||
|
||||
return {
|
||||
importStatement,
|
||||
extension: `Plugin${index}`,
|
||||
}
|
||||
}),
|
||||
...(hasLocalExtensions
|
||||
? [
|
||||
{
|
||||
importStatement: `import LocalEntry from "./_local-entry"`,
|
||||
extension: `LocalEntry`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const extensionsEntry = dedent`
|
||||
${extensionsArray
|
||||
.map((extension) => extension.importStatement)
|
||||
.join("\n")}
|
||||
|
||||
const extensions = [
|
||||
${extensionsArray.map((extension) => extension.extension).join(",\n")}
|
||||
]
|
||||
|
||||
export default extensions
|
||||
`
|
||||
|
||||
try {
|
||||
await fse.outputFile(
|
||||
path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"),
|
||||
extensionsEntry
|
||||
)
|
||||
} catch (err) {
|
||||
logger.panic(
|
||||
`Failed to write the extensions entry file. See the error below for details:`,
|
||||
{
|
||||
error: err,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateEntryArgs = {
|
||||
appDir: string
|
||||
dest: string
|
||||
plugins?: string[]
|
||||
}
|
||||
|
||||
export async function createEntry({ appDir, dest, plugins }: CreateEntryArgs) {
|
||||
const hasLocalExtensions = await createLocalExtensionsEntry(appDir, dest)
|
||||
|
||||
const adminPlugins = findPluginsWithExtensions(plugins)
|
||||
|
||||
await createMainExtensionsEntry(dest, adminPlugins, hasLocalExtensions)
|
||||
await writeTailwindContentFile(dest, adminPlugins)
|
||||
}
|
||||
61
packages/admin-ui/src/node/utils/get-client-env.ts
Normal file
61
packages/admin-ui/src/node/utils/get-client-env.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import dotenv from "dotenv"
|
||||
import fse from "fs-extra"
|
||||
import path from "node:path"
|
||||
|
||||
const MEDUSA_ADMIN = /^MEDUSA_ADMIN_/i
|
||||
|
||||
let ENV_FILE_NAME = ""
|
||||
switch (process.env.NODE_ENV) {
|
||||
case "production":
|
||||
ENV_FILE_NAME = ".env.production"
|
||||
break
|
||||
case "staging":
|
||||
ENV_FILE_NAME = ".env.staging"
|
||||
break
|
||||
case "test":
|
||||
ENV_FILE_NAME = ".env.test"
|
||||
break
|
||||
case "development":
|
||||
default:
|
||||
ENV_FILE_NAME = ".env"
|
||||
break
|
||||
}
|
||||
|
||||
if (fse.existsSync(ENV_FILE_NAME)) {
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ENV_FILE_NAME) })
|
||||
} else if (ENV_FILE_NAME !== ".env") {
|
||||
// Fall back to .env if the specified file does not exist
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") })
|
||||
}
|
||||
|
||||
type GetClientEnvArgs = {
|
||||
path?: string
|
||||
env?: string
|
||||
backend?: string
|
||||
}
|
||||
|
||||
export const getClientEnv = (args: GetClientEnvArgs) => {
|
||||
const raw = Object.keys(process.env)
|
||||
.filter((key) => MEDUSA_ADMIN.test(key))
|
||||
.reduce(
|
||||
(acc, current) => {
|
||||
acc[current] = process.env[current]
|
||||
|
||||
return acc
|
||||
},
|
||||
{
|
||||
ADMIN_PATH: args.path || "/",
|
||||
NODE_ENV: args.env || "development",
|
||||
MEDUSA_BACKEND_URL: args.backend || process.env.MEDUSA_BACKEND_URL,
|
||||
}
|
||||
)
|
||||
|
||||
const stringified = {
|
||||
"process.env": Object.keys(raw).reduce((env, key) => {
|
||||
env[key] = JSON.stringify(raw[key])
|
||||
return env
|
||||
}, {}),
|
||||
}
|
||||
|
||||
return stringified
|
||||
}
|
||||
27
packages/admin-ui/src/node/utils/index.ts
Normal file
27
packages/admin-ui/src/node/utils/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createCacheDir } from "./create-cache-dir"
|
||||
import { getClientEnv } from "./get-client-env"
|
||||
import { logger } from "./logger"
|
||||
import { normalizePath } from "./normalize-path"
|
||||
import {
|
||||
findAllValidRoutes,
|
||||
findAllValidSettings,
|
||||
findAllValidWidgets,
|
||||
validateRoute,
|
||||
validateSetting,
|
||||
validateWidget,
|
||||
} from "./validate-extensions"
|
||||
import { watchLocalAdminFolder } from "./watch-local-admin-folder"
|
||||
|
||||
export {
|
||||
logger,
|
||||
normalizePath,
|
||||
getClientEnv,
|
||||
createCacheDir,
|
||||
validateWidget,
|
||||
validateRoute,
|
||||
validateSetting,
|
||||
findAllValidWidgets,
|
||||
findAllValidRoutes,
|
||||
findAllValidSettings,
|
||||
watchLocalAdminFolder,
|
||||
}
|
||||
74
packages/admin-ui/src/node/utils/logger.ts
Normal file
74
packages/admin-ui/src/node/utils/logger.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import colors from "picocolors"
|
||||
import readline from "readline"
|
||||
|
||||
const prefix = "[@medusajs/admin]"
|
||||
|
||||
type LogType = "error" | "warn" | "info"
|
||||
|
||||
interface LogOptions {
|
||||
clearScreen?: boolean
|
||||
}
|
||||
|
||||
interface LogErrorOptions extends LogOptions {
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info(msg: string, options?: LogOptions): void
|
||||
warn(msg: string, options?: LogOptions): void
|
||||
error(msg: string, options?: LogErrorOptions): void
|
||||
panic(msg: string, options?: LogErrorOptions): void
|
||||
}
|
||||
|
||||
function clearScreen() {
|
||||
const repeatCount = process.stdout.rows - 2
|
||||
const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : ""
|
||||
console.log(blank)
|
||||
readline.cursorTo(process.stdout, 0, 0)
|
||||
readline.clearScreenDown(process.stdout)
|
||||
}
|
||||
|
||||
const canClearScreen = process.stdout.isTTY && !process.env.CI
|
||||
const clear = canClearScreen
|
||||
? clearScreen
|
||||
: () => {
|
||||
// noop
|
||||
}
|
||||
|
||||
function createLogger(): Logger {
|
||||
const output = (type: LogType, msg: string, options?: LogErrorOptions) => {
|
||||
const method = type === "info" ? "log" : type
|
||||
const format = () => {
|
||||
const tag =
|
||||
type === "info"
|
||||
? colors.cyan(colors.bold(prefix))
|
||||
: type === "warn"
|
||||
? colors.yellow(colors.bold(prefix))
|
||||
: colors.red(colors.bold(prefix))
|
||||
return `${colors.dim(new Date().toLocaleTimeString())} ${tag} ${msg}`
|
||||
}
|
||||
|
||||
if (options?.clearScreen) {
|
||||
clear()
|
||||
}
|
||||
|
||||
console[method](format())
|
||||
|
||||
if (options?.error) {
|
||||
console.error(options.error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
info: (msg, options) => output("info", msg, options),
|
||||
warn: (msg, options) => output("warn", msg, options),
|
||||
error: (msg, options) => output("error", msg, options),
|
||||
panic: (msg, options) => {
|
||||
output("error", msg, options)
|
||||
output("error", "Exiting process", {})
|
||||
process.exit(1)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = createLogger()
|
||||
6
packages/admin-ui/src/node/utils/normalize-path.ts
Normal file
6
packages/admin-ui/src/node/utils/normalize-path.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function normalizePath(path: string): string {
|
||||
const isWindows = process.platform === "win32"
|
||||
const separator = isWindows ? "\\" : "/"
|
||||
const regex = new RegExp(`\\${separator}`, "g")
|
||||
return path.replace(regex, "/")
|
||||
}
|
||||
28
packages/admin-ui/src/node/utils/validate-args.ts
Normal file
28
packages/admin-ui/src/node/utils/validate-args.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CustomWebpackConfigArgs } from "../types"
|
||||
import { logger } from "./logger"
|
||||
|
||||
function validateArgs(args: CustomWebpackConfigArgs) {
|
||||
const { options } = args
|
||||
|
||||
if (options.path) {
|
||||
if (!options.path.startsWith("/")) {
|
||||
logger.panic(
|
||||
"'path' in the options of `@medusajs/admin` must start with a '/'"
|
||||
)
|
||||
}
|
||||
|
||||
if (options.path !== "/" && options.path.endsWith("/")) {
|
||||
logger.panic(
|
||||
"'path' in the options of `@medusajs/admin` cannot end with a '/'"
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof options.path !== "string") {
|
||||
logger.panic(
|
||||
"'path' in the options of `@medusajs/admin` must be a string"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { validateArgs }
|
||||
685
packages/admin-ui/src/node/utils/validate-extensions.ts
Normal file
685
packages/admin-ui/src/node/utils/validate-extensions.ts
Normal file
@@ -0,0 +1,685 @@
|
||||
import { parse, ParseResult, ParserOptions } from "@babel/parser"
|
||||
import traverse, { NodePath } from "@babel/traverse"
|
||||
import type {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
ObjectExpression,
|
||||
ObjectMethod,
|
||||
ObjectProperty,
|
||||
SpreadElement,
|
||||
} from "@babel/types"
|
||||
import fse from "fs-extra"
|
||||
import path from "path"
|
||||
import { forbiddenRoutes, InjectionZone, injectionZones } from "../../client"
|
||||
import { logger } from "./logger"
|
||||
import { normalizePath } from "./normalize-path"
|
||||
|
||||
function isValidInjectionZone(zone: any): zone is InjectionZone {
|
||||
return injectionZones.includes(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the widget config export is valid.
|
||||
* In order to be valid it must have a `zone` property that is either a `InjectionZone` or a `InjectionZone` array.
|
||||
*/
|
||||
function validateWidgetConfigExport(
|
||||
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||
): boolean {
|
||||
const zoneProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "zone"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
return false
|
||||
}
|
||||
|
||||
let zoneIsValid = false
|
||||
|
||||
if (zoneProperty.value.type === "StringLiteral") {
|
||||
zoneIsValid = isValidInjectionZone(zoneProperty.value.value)
|
||||
} else if (zoneProperty.value.type === "ArrayExpression") {
|
||||
zoneIsValid = zoneProperty.value.elements.every(
|
||||
(zone) =>
|
||||
zone.type === "StringLiteral" && isValidInjectionZone(zone.value)
|
||||
)
|
||||
}
|
||||
|
||||
return zoneIsValid
|
||||
}
|
||||
|
||||
function validateRouteConfigExport(
|
||||
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||
): boolean {
|
||||
const linkProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "link"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
// Link property is optional for routes
|
||||
if (!linkProperty) {
|
||||
return true
|
||||
}
|
||||
|
||||
const linkValue = linkProperty.value as ObjectExpression
|
||||
|
||||
let labelIsValid = false
|
||||
|
||||
// Check that the linkProperty is an object and has a `label` property that is a string
|
||||
if (
|
||||
linkValue.properties.some(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "label" &&
|
||||
p.value.type === "StringLiteral"
|
||||
)
|
||||
) {
|
||||
labelIsValid = true
|
||||
}
|
||||
|
||||
return labelIsValid
|
||||
}
|
||||
|
||||
function validateSettingConfigExport(
|
||||
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||
): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
function validateConfigExport(
|
||||
path: NodePath<ExportNamedDeclaration>,
|
||||
type: "widget" | "route" | "setting"
|
||||
) {
|
||||
let hasValidConfigExport = false
|
||||
|
||||
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"
|
||||
) {
|
||||
const properties = configDeclaration.init.properties
|
||||
|
||||
if (type === "widget") {
|
||||
hasValidConfigExport = validateWidgetConfigExport(properties)
|
||||
}
|
||||
|
||||
if (type === "route") {
|
||||
hasValidConfigExport = validateRouteConfigExport(properties)
|
||||
}
|
||||
|
||||
if (type === "setting") {
|
||||
hasValidConfigExport = validateSettingConfigExport(properties)
|
||||
}
|
||||
} else {
|
||||
hasValidConfigExport = false
|
||||
}
|
||||
}
|
||||
|
||||
return hasValidConfigExport
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the default export of a file is a valid React component.
|
||||
* This is determined by checking if the default export is a function declaration
|
||||
* with a return statement that returns a JSX element or fragment.
|
||||
*/
|
||||
function validateDefaultExport(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
ast: ParseResult<any>
|
||||
) {
|
||||
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) {
|
||||
logger.error(
|
||||
`There was an error while validating the default export of ${path}. The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasComponentExport
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a widget file has a valid default export and a valid config export.
|
||||
*
|
||||
*/
|
||||
async function validateWidget(file: string) {
|
||||
const content = await fse.readFile(file, "utf-8")
|
||||
|
||||
const parserOptions: ParserOptions = {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx"],
|
||||
}
|
||||
|
||||
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||
parserOptions.plugins.push("typescript")
|
||||
}
|
||||
|
||||
let ast: ParseResult<any>
|
||||
|
||||
try {
|
||||
ast = parse(content, parserOptions)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while parsing the Widget "${file}", and the Widget cannot be injected. The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let hasConfigExport = false
|
||||
let hasComponentExport = false
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration: (path) => {
|
||||
hasComponentExport = validateDefaultExport(path, ast)
|
||||
},
|
||||
ExportNamedDeclaration: (path) => {
|
||||
hasConfigExport = validateConfigExport(path, "widget")
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while validating the Widget "${file}". The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasConfigExport && !hasComponentExport) {
|
||||
if (!hasComponentExport) {
|
||||
logger.error(
|
||||
`The default export in the Widget "${file}" is invalid and the widget will not be injected. Please make sure that the default export is a valid React component.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasConfigExport && hasComponentExport) {
|
||||
logger.error(
|
||||
`The Widget config export in "${file}" is invalid and the Widget cannot be injected. Please ensure that the config is valid.`
|
||||
)
|
||||
}
|
||||
|
||||
return hasConfigExport && hasComponentExport
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes a file path and converts it to a URL path.
|
||||
* It converts the file path to a URL path by replacing any
|
||||
* square brackets with colons, and then removing the "page.[jt]s" suffix.
|
||||
*/
|
||||
function createPath(filePath: string): string {
|
||||
const normalizedPath = normalizePath(filePath)
|
||||
|
||||
const regex = /\[(.*?)\]/g
|
||||
const strippedPath = normalizedPath.replace(regex, ":$1")
|
||||
|
||||
const url = strippedPath.replace(/\/page\.[jt]sx?$/i, "")
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function isForbiddenRoute(path: any): boolean {
|
||||
return forbiddenRoutes.includes(path)
|
||||
}
|
||||
|
||||
function validatePath(
|
||||
path: string,
|
||||
origin: string
|
||||
): {
|
||||
valid: boolean
|
||||
error: string
|
||||
} {
|
||||
if (isForbiddenRoute(path)) {
|
||||
return {
|
||||
error: `A route from ${origin} is using a forbidden path: ${path}.`,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
const specialChars = ["/", ":", "-"]
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const currentChar = path[i]
|
||||
|
||||
if (
|
||||
!specialChars.includes(currentChar) &&
|
||||
!/^[a-z0-9]$/i.test(currentChar)
|
||||
) {
|
||||
return {
|
||||
error: `A route from ${origin} is using an invalid path: ${path}. Only alphanumeric characters, "/", ":", and "-" are allowed.`,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChar === ":" && (i === 0 || path[i - 1] !== "/")) {
|
||||
return {
|
||||
error: `A route from ${origin} is using an invalid path: ${path}. All dynamic segments must be preceded by a "/".`,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
error: "",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file is a valid route.
|
||||
* This is determined by checking if the file exports a valid React component
|
||||
* as the default export, and a optional route config as a named export.
|
||||
* If the file is not a valid route, `null` is returned.
|
||||
* If the file is a valid route, a `ValidRouteResult` is returned.
|
||||
*/
|
||||
async function validateRoute(
|
||||
file: string,
|
||||
basePath: string
|
||||
): Promise<{
|
||||
path: string
|
||||
hasConfig: boolean
|
||||
file: string
|
||||
} | null> {
|
||||
const cleanPath = createPath(file.replace(basePath, ""))
|
||||
|
||||
const { valid, error } = validatePath(cleanPath, file)
|
||||
|
||||
if (!valid) {
|
||||
logger.error(
|
||||
`The path ${cleanPath} for the UI Route "${file}" is invalid and the route cannot be injected. The following error must be fixed before the route can be injected: ${error}`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const content = await fse.readFile(file, "utf-8")
|
||||
|
||||
let hasComponentExport = false
|
||||
let hasConfigExport = false
|
||||
|
||||
const parserOptions: ParserOptions = {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx"],
|
||||
}
|
||||
|
||||
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||
parserOptions.plugins.push("typescript")
|
||||
}
|
||||
|
||||
let ast: ParseResult<any>
|
||||
|
||||
try {
|
||||
ast = parse(content, parserOptions)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while parsing the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration: (path) => {
|
||||
hasComponentExport = validateDefaultExport(path, ast)
|
||||
},
|
||||
ExportNamedDeclaration: (path) => {
|
||||
hasConfigExport = validateConfigExport(path, "route")
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while validating the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasComponentExport) {
|
||||
logger.error(
|
||||
`The default export in the UI Route "${file}" is invalid and the route cannot be injected. Please make sure that the default export is a valid React component.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
path: cleanPath,
|
||||
hasConfig: hasConfigExport,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSetting(file: string, basePath: string) {
|
||||
const cleanPath = createPath(file.replace(basePath, ""))
|
||||
|
||||
const { valid, error } = validatePath(cleanPath, file)
|
||||
|
||||
if (!valid) {
|
||||
logger.error(
|
||||
`The path ${cleanPath} for the Setting "${file}" is invalid and the setting cannot be injected. The following error must be fixed before the Setting can be injected: ${error}`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const content = await fse.readFile(file, "utf-8")
|
||||
|
||||
let hasComponentExport = false
|
||||
let hasConfigExport = false
|
||||
|
||||
const parserOptions: ParserOptions = {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx"],
|
||||
}
|
||||
|
||||
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||
parserOptions.plugins.push("typescript")
|
||||
}
|
||||
|
||||
let ast: ParseResult<any>
|
||||
|
||||
try {
|
||||
ast = parse(content, parserOptions)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`
|
||||
An error occured while parsing the Setting "${file}". The following error must be resolved before continuing:
|
||||
`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration: (path) => {
|
||||
hasComponentExport = validateDefaultExport(path, ast)
|
||||
},
|
||||
ExportNamedDeclaration: (path) => {
|
||||
hasConfigExport = validateConfigExport(path, "setting")
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`
|
||||
An error occured while validating the Setting "${file}". The following error must be resolved before continuing:`,
|
||||
{
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasComponentExport) {
|
||||
logger.error(
|
||||
`The default export in the Setting "${file}" is invalid and the page will not be injected. Please make sure that the default export is a valid React component.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasConfigExport) {
|
||||
logger.error(
|
||||
`The named export "config" in the Setting "${file}" is invalid or missing and the settings page will not be injected. Please make sure that the file exports a valid config.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
path: cleanPath,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
async function findAllValidSettings(dir: string) {
|
||||
const settingsFiles: string[] = []
|
||||
|
||||
const dirExists = await fse.pathExists(dir)
|
||||
|
||||
if (!dirExists) {
|
||||
return []
|
||||
}
|
||||
|
||||
const paths = await fse.readdir(dir)
|
||||
|
||||
let hasSubDirs = false
|
||||
|
||||
// We only check the first level of directories for settings files
|
||||
for (const pa of paths) {
|
||||
const filePath = path.join(dir, pa)
|
||||
const fileStat = await fse.stat(filePath)
|
||||
|
||||
if (fileStat.isDirectory()) {
|
||||
const files = await fse.readdir(filePath)
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, pa, file)
|
||||
const fileStat = await fse.stat(filePath)
|
||||
|
||||
if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) {
|
||||
settingsFiles.push(filePath)
|
||||
break
|
||||
} else if (fileStat.isDirectory()) {
|
||||
hasSubDirs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSubDirs) {
|
||||
logger.warn(
|
||||
`The directory ${dir} contains subdirectories. Settings do not support nested routes, only UI Routes support nested paths.`
|
||||
)
|
||||
}
|
||||
|
||||
const validSettingsFiles = await Promise.all(
|
||||
settingsFiles.map(async (file) => validateSetting(file, dir))
|
||||
)
|
||||
|
||||
return validSettingsFiles.filter((file) => file !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a directory for valid widgets.
|
||||
* A valid widget is a file that exports a valid widget config and a valid React component.
|
||||
*/
|
||||
async function findAllValidWidgets(dir: string) {
|
||||
const jsxAndTsxFiles: string[] = []
|
||||
|
||||
const dirExists = await fse.pathExists(dir)
|
||||
|
||||
if (!dirExists) {
|
||||
return []
|
||||
}
|
||||
|
||||
async function traverseDirectory(currentPath: string) {
|
||||
const files = await fse.readdir(currentPath)
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(currentPath, file)
|
||||
const fileStat = await fse.stat(filePath)
|
||||
|
||||
if (fileStat.isDirectory()) {
|
||||
await traverseDirectory(filePath)
|
||||
} else if (fileStat.isFile() && /\.(js|jsx|ts|tsx)$/i.test(file)) {
|
||||
jsxAndTsxFiles.push(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await traverseDirectory(dir)
|
||||
|
||||
const promises = jsxAndTsxFiles.map((file) => {
|
||||
const isValid = validateWidget(file)
|
||||
|
||||
return isValid ? file : null
|
||||
})
|
||||
|
||||
const validFiles = await Promise.all(promises)
|
||||
|
||||
return validFiles.filter((file) => file !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a directory for valid routes.
|
||||
* A valid route is a file that exports a optional route config and a valid React component.
|
||||
*/
|
||||
async function findAllValidRoutes(dir: string) {
|
||||
const pageFiles: string[] = []
|
||||
|
||||
const dirExists = await fse.pathExists(dir)
|
||||
|
||||
if (!dirExists) {
|
||||
return []
|
||||
}
|
||||
|
||||
async function traverseDirectory(currentPath: string) {
|
||||
const files = await fse.readdir(currentPath)
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(currentPath, file)
|
||||
const fileStat = await fse.stat(filePath)
|
||||
|
||||
if (fileStat.isDirectory()) {
|
||||
await traverseDirectory(filePath)
|
||||
} else if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) {
|
||||
pageFiles.push(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await traverseDirectory(dir)
|
||||
|
||||
const promises = pageFiles.map(async (file) => {
|
||||
return validateRoute(file, dir)
|
||||
})
|
||||
|
||||
const validFiles = await Promise.all(promises)
|
||||
|
||||
return validFiles.filter((file) => file !== null)
|
||||
}
|
||||
|
||||
export {
|
||||
createPath,
|
||||
validateWidget,
|
||||
validateRoute,
|
||||
validateSetting,
|
||||
findAllValidSettings,
|
||||
findAllValidWidgets,
|
||||
findAllValidRoutes,
|
||||
}
|
||||
57
packages/admin-ui/src/node/utils/watch-local-admin-folder.ts
Normal file
57
packages/admin-ui/src/node/utils/watch-local-admin-folder.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import chokidar from "chokidar"
|
||||
import fse from "fs-extra"
|
||||
import path from "node:path"
|
||||
import { createEntry } from "./create-entry"
|
||||
import { logger } from "./logger"
|
||||
|
||||
/**
|
||||
* Watches the local admin directory for changes and updates the extensions cache directory accordingly.
|
||||
*/
|
||||
export async function watchLocalAdminFolder(
|
||||
appDir: string,
|
||||
cacheDir: string,
|
||||
plugins: string[]
|
||||
) {
|
||||
const adminDir = path.resolve(appDir, "src", "admin")
|
||||
|
||||
const watcher = chokidar.watch(adminDir, {
|
||||
ignored: /(^|[/\\])\../,
|
||||
ignoreInitial: true,
|
||||
})
|
||||
|
||||
watcher.on("all", async (event, file) => {
|
||||
if (event === "unlinkDir" || event === "unlink") {
|
||||
removeUnlinkedFile(file, appDir, cacheDir)
|
||||
}
|
||||
|
||||
await createEntry({
|
||||
appDir,
|
||||
dest: cacheDir,
|
||||
plugins,
|
||||
})
|
||||
|
||||
logger.info("Extensions cache directory was re-initialized")
|
||||
})
|
||||
|
||||
process
|
||||
.on("SIGINT", async () => {
|
||||
await watcher.close()
|
||||
})
|
||||
.on("SIGTERM", async () => {
|
||||
await watcher.close()
|
||||
})
|
||||
}
|
||||
|
||||
function removeUnlinkedFile(file: string, appDir: string, cacheDir: string) {
|
||||
const srcDir = path.resolve(appDir, "src", "admin")
|
||||
const relativePath = path.relative(srcDir, file)
|
||||
|
||||
const destDir = path.resolve(cacheDir, "admin", "src", "extensions")
|
||||
const fileToDelete = path.resolve(destDir, relativePath)
|
||||
|
||||
try {
|
||||
fse.removeSync(fileToDelete)
|
||||
} catch (error) {
|
||||
logger.error(`An error occurred while removing ${fileToDelete}: ${error}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user