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:
Kasper Fabricius Kristensen
2023-08-17 14:14:45 +02:00
committed by GitHub
parent 26c78bbc03
commit f1a05f4725
189 changed files with 14570 additions and 12773 deletions

View File

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

View File

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

View 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
}

View 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 }

View 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)
}

View 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
}

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

View 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()

View 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, "/")
}

View 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 }

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

View 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}`)
}
}