feat(dashboard,admin-vite-plugin,admin-bundler,admin-sdk): Rework admin extensions and introduce custom fields API (#9338)
This commit is contained in:
committed by
GitHub
parent
35e69d32f2
commit
d71343d6ab
@@ -25,8 +25,7 @@
|
||||
"watch": "tsup --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "7.22.5",
|
||||
"@types/babel__traverse": "7.20.5",
|
||||
"@babel/types": "7.25.6",
|
||||
"@types/node": "^20.10.4",
|
||||
"tsup": "8.0.1",
|
||||
"typescript": "5.3.3",
|
||||
@@ -36,12 +35,14 @@
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "7.23.5",
|
||||
"@babel/traverse": "7.23.5",
|
||||
"@babel/parser": "7.25.6",
|
||||
"@babel/traverse": "7.25.6",
|
||||
"@medusajs/admin-shared": "0.0.1",
|
||||
"chokidar": "3.5.3",
|
||||
"fdir": "6.1.1",
|
||||
"magic-string": "0.30.5"
|
||||
"magic-string": "0.30.5",
|
||||
"outdent": "^0.8.0",
|
||||
"picocolors": "^1.1.0"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,24 @@ import {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isFunctionDeclaration,
|
||||
isIdentifier,
|
||||
isJSXElement,
|
||||
isJSXFragment,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
ObjectExpression,
|
||||
ObjectMethod,
|
||||
ObjectProperty,
|
||||
SpreadElement,
|
||||
StringLiteral,
|
||||
} from "@babel/types"
|
||||
|
||||
/**
|
||||
@@ -20,13 +37,33 @@ if (typeof _traverse === "function") {
|
||||
traverse = (_traverse as any).default
|
||||
}
|
||||
|
||||
export { parse, traverse }
|
||||
export {
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isFunctionDeclaration,
|
||||
isIdentifier,
|
||||
isJSXElement,
|
||||
isJSXFragment,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
parse,
|
||||
traverse,
|
||||
}
|
||||
export type {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
File,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectMethod,
|
||||
ObjectProperty,
|
||||
ParseResult,
|
||||
ParserOptions,
|
||||
SpreadElement,
|
||||
StringLiteral,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
isValidCustomFieldDisplayPath,
|
||||
isValidCustomFieldDisplayZone,
|
||||
type CustomFieldContainerZone,
|
||||
type CustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
NodePath,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel, validateLink } from "./helpers"
|
||||
|
||||
type CustomFieldDisplay = {
|
||||
zone: CustomFieldContainerZone
|
||||
Component: string
|
||||
}
|
||||
|
||||
type ParsedCustomFieldDisplayConfig = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
displays: CustomFieldDisplay[] | null
|
||||
}
|
||||
|
||||
export async function generateCustomFieldDisplays(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldDisplayResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateDisplayCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateDisplayCode(
|
||||
results: ParsedCustomFieldDisplayConfig[]
|
||||
): string {
|
||||
const groupedByModel = new Map<
|
||||
CustomFieldModel,
|
||||
ParsedCustomFieldDisplayConfig[]
|
||||
>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const displays = results
|
||||
.map((result) => formatDisplays(result.displays))
|
||||
.filter((display) => display !== "")
|
||||
.join(",\n")
|
||||
|
||||
segments.push(`
|
||||
${model}: [
|
||||
${displays}
|
||||
],
|
||||
`)
|
||||
})
|
||||
|
||||
return `
|
||||
displays: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatDisplays(displays: CustomFieldDisplay[] | null): string {
|
||||
if (!displays || displays.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return displays
|
||||
.map(
|
||||
(display) => `
|
||||
{
|
||||
zone: "${display.zone}",
|
||||
Component: ${display.Component},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")
|
||||
}
|
||||
|
||||
async function getCustomFieldDisplayResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldDisplayConfig[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => parseDisplayFile(file, index))
|
||||
)
|
||||
).filter(Boolean) as ParsedCustomFieldDisplayConfig[]
|
||||
}
|
||||
|
||||
async function parseDisplayFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldDisplayConfig | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let displays: CustomFieldDisplay[] | null = null
|
||||
let model: CustomFieldModel | null = null
|
||||
let hasLink = false
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
displays = getDisplays(path, model, index, file)
|
||||
hasLink = validateLink(path, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
logger.warn(`'model' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
displays,
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplays(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldDisplay[] | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "displays" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!displayProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isArrayExpression(displayProperty.value)) {
|
||||
logger.warn(
|
||||
`'displays' is not an array. The 'displays' property must be an array of objects.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const displays: CustomFieldDisplay[] = []
|
||||
|
||||
displayProperty.value.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing at the ${j} index of the 'displays' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at index ${j} in the 'displays' property is not a string literal. 'zone' must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getDisplayEntryPath(model, zone)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldDisplayZone(zone) ||
|
||||
!isValidCustomFieldDisplayPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' is invalid at index ${j} in the 'displays' property. Received: ${zone}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const componentProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "component" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!componentProperty) {
|
||||
logger.warn(
|
||||
`'component' property is missing at index ${j} in the 'displays' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
displays.push({
|
||||
zone: zone,
|
||||
Component: getDisplayComponent(index, j),
|
||||
})
|
||||
})
|
||||
|
||||
return displays.length > 0 ? displays : null
|
||||
}
|
||||
|
||||
function getDisplayEntryPath(model: CustomFieldModel, zone: string): string {
|
||||
return `${model}.${zone}.$display`
|
||||
}
|
||||
|
||||
function getDisplayComponent(
|
||||
fileIndex: number,
|
||||
displayEntryIndex: number
|
||||
): string {
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.displays[${displayEntryIndex}].component`
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
import { ArrayExpression } from "@babel/types"
|
||||
import {
|
||||
isValidCustomFieldFormConfigPath,
|
||||
isValidCustomFieldFormFieldPath,
|
||||
isValidCustomFieldFormTab,
|
||||
isValidCustomFieldFormZone,
|
||||
type CustomFieldFormTab,
|
||||
type CustomFieldFormZone,
|
||||
type CustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isArrayExpression,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isMemberExpression,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel, validateLink } from "./helpers"
|
||||
|
||||
type CustomFieldConfigField = {
|
||||
name: string
|
||||
defaultValue: string
|
||||
validation: string
|
||||
}
|
||||
|
||||
type CustomFieldConfig = {
|
||||
zone: CustomFieldFormZone
|
||||
fields: CustomFieldConfigField[]
|
||||
}
|
||||
|
||||
type CustomFieldFormField = {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
placeholder: string
|
||||
Component: string
|
||||
validation: string
|
||||
}
|
||||
|
||||
type CustomFieldFormSection = {
|
||||
zone: CustomFieldFormZone
|
||||
tab?: CustomFieldFormTab
|
||||
fields: CustomFieldFormField[]
|
||||
}
|
||||
|
||||
type ParsedCustomFieldConfig = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
configs: CustomFieldConfig[] | null
|
||||
forms: CustomFieldFormSection[] | null
|
||||
}
|
||||
|
||||
export async function generateCustomFieldForms(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedCustomFieldConfig[]): string {
|
||||
const groupedByModel = new Map<CustomFieldModel, ParsedCustomFieldConfig[]>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const configs = results
|
||||
.map((result) => formatConfig(result.configs))
|
||||
.filter((config) => config !== "")
|
||||
.join(",\n")
|
||||
const forms = results
|
||||
.map((result) => formatForms(result.forms))
|
||||
.filter((form) => form !== "")
|
||||
.join(",\n")
|
||||
|
||||
segments.push(outdent`
|
||||
${model}: {
|
||||
configs: [
|
||||
${configs}
|
||||
],
|
||||
forms: [
|
||||
${forms}
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
return outdent`
|
||||
customFields: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatConfig(configs: CustomFieldConfig[] | null): string {
|
||||
if (!configs || configs.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return outdent`
|
||||
${configs
|
||||
.map(
|
||||
(config) => outdent`
|
||||
{
|
||||
zone: "${config.zone}",
|
||||
fields: {
|
||||
${config.fields
|
||||
.map(
|
||||
(field) => `${field.name}: {
|
||||
defaultValue: ${field.defaultValue},
|
||||
validation: ${field.validation},
|
||||
}`
|
||||
)
|
||||
.join(",\n")}
|
||||
},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")}
|
||||
`
|
||||
}
|
||||
|
||||
function formatForms(forms: CustomFieldFormSection[] | null): string {
|
||||
if (!forms || forms.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return forms
|
||||
.map(
|
||||
(form) => outdent`
|
||||
{
|
||||
zone: "${form.zone}",
|
||||
tab: ${form.tab === undefined ? undefined : `"${form.tab}"`},
|
||||
fields: {
|
||||
${form.fields
|
||||
.map(
|
||||
(field) => `${field.name}: {
|
||||
validation: ${field.validation},
|
||||
Component: ${field.Component},
|
||||
label: ${field.label},
|
||||
description: ${field.description},
|
||||
placeholder: ${field.placeholder},
|
||||
}`
|
||||
)
|
||||
.join(",\n")}
|
||||
},
|
||||
}
|
||||
`
|
||||
)
|
||||
.join(",\n")
|
||||
}
|
||||
|
||||
async function getCustomFieldResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldConfig[]> {
|
||||
return (
|
||||
await Promise.all(files.map(async (file, index) => parseFile(file, index)))
|
||||
).filter(Boolean) as ParsedCustomFieldConfig[]
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldConfig | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let configs: CustomFieldConfig[] | null = []
|
||||
let forms: CustomFieldFormSection[] | null = []
|
||||
let model: CustomFieldModel | null = null
|
||||
let hasLink = false
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
hasLink = validateLink(path, file) // Add this line to validate link
|
||||
configs = getConfigs(path, model, index, file)
|
||||
forms = getForms(path, model, index, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
logger.warn(`'model' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasLink) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
configs,
|
||||
forms,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
|
||||
function getForms(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldFormSection[] | null {
|
||||
const formArray = getFormsArgument(path, file)
|
||||
|
||||
if (!formArray) {
|
||||
return null
|
||||
}
|
||||
|
||||
const forms: CustomFieldFormSection[] = []
|
||||
|
||||
formArray.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing from the ${j} index of the 'forms' property. The 'zone' property is required to load a custom field form.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a string literal. The 'zone' property must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const tabProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "tab" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
let tab: string | undefined
|
||||
|
||||
if (tabProperty) {
|
||||
if (!isStringLiteral(tabProperty.value)) {
|
||||
logger.warn(
|
||||
`'tab' property at the ${j} index of the 'forms' property is not a string literal. The 'tab' property must be a string literal, e.g. 'general' or 'attributes'.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tab = tabProperty.value.value
|
||||
}
|
||||
|
||||
if (tab && !isValidCustomFieldFormTab(tab)) {
|
||||
logger.warn(
|
||||
`'tab' property at the ${j} index of the 'forms' property is not a valid custom field form tab for the '${model}' model. Received: ${tab}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getFormEntryFieldPath(model, zone, tab)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldFormZone(zone) ||
|
||||
!isValidCustomFieldFormFieldPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' and 'tab' properties at the ${j} index of the 'forms' property are not a valid for the '${model}' model. Received: { zone: ${zone}, tab: ${tab} }.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldsObject = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "fields" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!fieldsObject) {
|
||||
logger.warn(
|
||||
`The 'fields' property is missing at the ${j} index of the 'forms' property. The 'fields' property is required to load a custom field form.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fields: CustomFieldFormField[] = []
|
||||
|
||||
if (!isObjectExpression(fieldsObject.value)) {
|
||||
logger.warn(
|
||||
`The 'fields' property at the ${j} index of the 'forms' property is malformed. The 'fields' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
fieldsObject.value.properties.forEach((field) => {
|
||||
if (!isObjectProperty(field) || !isIdentifier(field.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = field.key.name
|
||||
|
||||
if (
|
||||
!isObjectExpression(field.value) &&
|
||||
!(
|
||||
isCallExpression(field.value) &&
|
||||
isMemberExpression(field.value.callee) &&
|
||||
isIdentifier(field.value.callee.object) &&
|
||||
isIdentifier(field.value.callee.property) &&
|
||||
field.value.callee.object.name === "form" &&
|
||||
field.value.callee.property.name === "define" &&
|
||||
field.value.arguments.length === 1 &&
|
||||
isObjectExpression(field.value.arguments[0])
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`'${name}' property in the 'fields' property at the ${j} index of the 'forms' property in ${file} is malformed. The property must be an object or a call to form.define().`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldObject = isObjectExpression(field.value)
|
||||
? field.value
|
||||
: (field.value.arguments[0] as ObjectExpression)
|
||||
|
||||
const labelProperty = fieldObject.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "label" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const descriptionProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "description" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const componentProperty = fieldObject.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "component" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const validationProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "validation" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const placeholderProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "placeholder" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
const label = getFormFieldSectionValue(
|
||||
!!labelProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"label"
|
||||
)
|
||||
const description = getFormFieldSectionValue(
|
||||
!!descriptionProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"description"
|
||||
)
|
||||
const placeholder = getFormFieldSectionValue(
|
||||
!!placeholderProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"placeholder"
|
||||
)
|
||||
const component = getFormFieldSectionValue(
|
||||
!!componentProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"component"
|
||||
)
|
||||
const validation = getFormFieldSectionValue(
|
||||
!!validationProperty,
|
||||
index,
|
||||
j,
|
||||
name,
|
||||
"validation"
|
||||
)
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
Component: component,
|
||||
validation,
|
||||
placeholder,
|
||||
})
|
||||
})
|
||||
|
||||
forms.push({
|
||||
zone,
|
||||
tab: tab as CustomFieldFormTab | undefined,
|
||||
fields,
|
||||
})
|
||||
})
|
||||
|
||||
return forms.length > 0 ? forms : null
|
||||
}
|
||||
|
||||
function getFormFieldSectionValue(
|
||||
exists: boolean,
|
||||
fileIndex: number,
|
||||
formIndex: number,
|
||||
fieldKey: string,
|
||||
value: string
|
||||
): string {
|
||||
if (!exists) {
|
||||
return "undefined"
|
||||
}
|
||||
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.forms[${formIndex}].fields.${fieldKey}.${value}`
|
||||
}
|
||||
|
||||
function getFormEntryFieldPath(
|
||||
model: CustomFieldModel,
|
||||
zone: string,
|
||||
tab?: string
|
||||
): string {
|
||||
return `${model}.${zone}.${tab ? `${tab}.` : ""}$field`
|
||||
}
|
||||
|
||||
function getConfigs(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
model: CustomFieldModel,
|
||||
index: number,
|
||||
file: string
|
||||
): CustomFieldConfig[] | null {
|
||||
const formArray = getFormsArgument(path, file)
|
||||
|
||||
if (!formArray) {
|
||||
logger.warn(`'forms' property is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const configs: CustomFieldConfig[] = []
|
||||
|
||||
formArray.elements.forEach((element, j) => {
|
||||
if (!isObjectExpression(element)) {
|
||||
logger.warn(
|
||||
`'forms' property at the ${j} index is malformed. The 'forms' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "zone" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(
|
||||
`'zone' property is missing from the ${j} index of the 'forms' property.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property cannot be a template literal (e.g. \`general\`).`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStringLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a string literal (e.g. 'general' or 'attributes').`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const zone = zoneProperty.value.value
|
||||
const fullPath = getFormEntryConfigPath(model, zone)
|
||||
|
||||
if (
|
||||
!isValidCustomFieldFormZone(zone) ||
|
||||
!isValidCustomFieldFormConfigPath(fullPath)
|
||||
) {
|
||||
logger.warn(
|
||||
`'zone' property at the ${j} index of the 'forms' property is not a valid custom field form zone for the '${model}' model. Received: ${zone}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldsObject = element.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "fields" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!fieldsObject) {
|
||||
logger.warn(
|
||||
`'fields' property is missing from the ${j} entry in the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fields: CustomFieldConfigField[] = []
|
||||
|
||||
if (!isObjectExpression(fieldsObject.value)) {
|
||||
logger.warn(
|
||||
`'fields' property at the ${j} index of the 'forms' property is malformed. The 'fields' property must be an object.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
fieldsObject.value.properties.forEach((field) => {
|
||||
if (!isObjectProperty(field) || !isIdentifier(field.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = field.key.name
|
||||
|
||||
if (
|
||||
!isObjectExpression(field.value) &&
|
||||
!(
|
||||
isCallExpression(field.value) &&
|
||||
isMemberExpression(field.value.callee) &&
|
||||
isIdentifier(field.value.callee.object) &&
|
||||
isIdentifier(field.value.callee.property) &&
|
||||
field.value.callee.object.name === "form" &&
|
||||
field.value.callee.property.name === "define" &&
|
||||
field.value.arguments.length === 1 &&
|
||||
isObjectExpression(field.value.arguments[0])
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`'${name}' property in the 'fields' property at the ${j} index of the 'forms' property in ${file} is malformed. The property must be an object or a call to form.define().`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldObject = isObjectExpression(field.value)
|
||||
? field.value
|
||||
: (field.value.arguments[0] as ObjectExpression)
|
||||
|
||||
const defaultValueProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "defaultValue" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!defaultValueProperty) {
|
||||
logger.warn(
|
||||
`'defaultValue' property is missing at the ${j} index of the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const validationProperty = fieldObject.properties.find(
|
||||
(p) =>
|
||||
isObjectProperty(p) && isIdentifier(p.key, { name: "validation" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!validationProperty) {
|
||||
logger.warn(
|
||||
`'validation' property is missing at the ${j} index of the 'forms' property in ${file}.`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const defaultValue = getFormFieldValue(index, j, name, "defaultValue")
|
||||
const validation = getFormFieldValue(index, j, name, "validation")
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
defaultValue,
|
||||
validation,
|
||||
})
|
||||
})
|
||||
|
||||
configs.push({
|
||||
zone: zone,
|
||||
fields,
|
||||
})
|
||||
})
|
||||
|
||||
return configs.length > 0 ? configs : null
|
||||
}
|
||||
|
||||
function getFormFieldValue(
|
||||
fileIndex: number,
|
||||
formIndex: number,
|
||||
fieldKey: string,
|
||||
value: string
|
||||
): string {
|
||||
const import_ = generateCustomFieldConfigName(fileIndex)
|
||||
return `${import_}.forms[${formIndex}].fields.${fieldKey}.${value}`
|
||||
}
|
||||
|
||||
function getFormEntryConfigPath(model: CustomFieldModel, zone: string): string {
|
||||
return `${model}.${zone}.$config`
|
||||
}
|
||||
|
||||
function getFormsArgument(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): ArrayExpression | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "forms" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!formProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isArrayExpression(formProperty.value)) {
|
||||
logger.warn(
|
||||
`The 'forms' property is malformed. The 'forms' property must be an array of objects.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return formProperty.value
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from "fs/promises"
|
||||
import { isIdentifier, isObjectProperty, parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, generateHash, getParserOptions } from "../utils"
|
||||
import { getConfigArgument } from "./helpers"
|
||||
|
||||
export async function generateCustomFieldHashes(
|
||||
sources: Set<string>
|
||||
): Promise<{ linkHash: string; formHash: string; displayHash: string }> {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getCustomFieldContents))
|
||||
|
||||
const linkContents = contents.map((c) => c.link).filter(Boolean)
|
||||
const formContents = contents.map((c) => c.form).filter(Boolean)
|
||||
const displayContents = contents.map((c) => c.display).filter(Boolean)
|
||||
|
||||
const totalLinkContent = linkContents.join("")
|
||||
const totalFormContent = formContents.join("")
|
||||
const totalDisplayContent = displayContents.join("")
|
||||
|
||||
return {
|
||||
linkHash: generateHash(totalLinkContent),
|
||||
formHash: generateHash(totalFormContent),
|
||||
displayHash: generateHash(totalDisplayContent),
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
async function getCustomFieldContents(file: string): Promise<{
|
||||
link: string | null
|
||||
form: string | null
|
||||
display: string | null
|
||||
}> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let linkContent: string | null = null
|
||||
let formContent: string | null = null
|
||||
let displayContent: string | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const configArgument = getConfigArgument(path)
|
||||
if (!configArgument) {
|
||||
return
|
||||
}
|
||||
|
||||
configArgument.properties.forEach((prop) => {
|
||||
if (!isObjectProperty(prop) || !prop.key || !isIdentifier(prop.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (prop.key.name) {
|
||||
case "link":
|
||||
linkContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
case "forms":
|
||||
formContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
case "display":
|
||||
displayContent = code.slice(prop.start!, prop.end!)
|
||||
break
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while processing ${file}. See the below error for more details:\n${e}`,
|
||||
{ file, error: e }
|
||||
)
|
||||
return { link: null, form: null, display: null }
|
||||
}
|
||||
|
||||
return { link: linkContent, form: formContent, display: displayContent }
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { CustomFieldModel } from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
File,
|
||||
isIdentifier,
|
||||
isObjectProperty,
|
||||
NodePath,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getParserOptions } from "../utils"
|
||||
import { getConfigArgument, getModel } from "./helpers"
|
||||
|
||||
type ParsedCustomFieldLink = {
|
||||
import: string
|
||||
model: CustomFieldModel
|
||||
link: string
|
||||
}
|
||||
|
||||
export async function generateCustomFieldLinks(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getCustomFieldLinkResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import)
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/custom-fields`)
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedCustomFieldLink[]): string {
|
||||
const groupedByModel = new Map<CustomFieldModel, ParsedCustomFieldLink[]>()
|
||||
|
||||
results.forEach((result) => {
|
||||
const model = result.model
|
||||
if (!groupedByModel.has(model)) {
|
||||
groupedByModel.set(model, [])
|
||||
}
|
||||
groupedByModel.get(model)!.push(result)
|
||||
})
|
||||
|
||||
const segments: string[] = []
|
||||
|
||||
groupedByModel.forEach((results, model) => {
|
||||
const links = results.map((result) => result.link).join(",\n")
|
||||
|
||||
segments.push(`
|
||||
${model}: [
|
||||
${links}
|
||||
],
|
||||
`)
|
||||
})
|
||||
|
||||
return `
|
||||
links: {
|
||||
${segments.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function getCustomFieldLinkResults(
|
||||
files: string[]
|
||||
): Promise<ParsedCustomFieldLink[]> {
|
||||
return (
|
||||
await Promise.all(files.map(async (file, index) => parseFile(file, index)))
|
||||
).filter(Boolean) as ParsedCustomFieldLink[]
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedCustomFieldLink | null> {
|
||||
const content = await fs.readFile(file, "utf8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(content, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file`, { file, error: e })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
|
||||
let link: string | null = null
|
||||
let model: CustomFieldModel | null = null
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
const _model = getModel(path, file)
|
||||
|
||||
if (!_model) {
|
||||
return
|
||||
}
|
||||
|
||||
model = _model
|
||||
link = getLink(path, index, file)
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: err,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!link || !model) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
model,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCustomFieldConfigName(index: number): string {
|
||||
return `CustomFieldConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateCustomFieldConfigName(index)} from "${file}"`
|
||||
}
|
||||
|
||||
function getLink(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
index: number,
|
||||
file: string
|
||||
): string | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linkProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!linkProperty) {
|
||||
logger.warn(`'link' is missing.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateCustomFieldConfigName(index)
|
||||
|
||||
return `${import_}.link`
|
||||
}
|
||||
116
packages/admin/admin-vite-plugin/src/custom-fields/helpers.ts
Normal file
116
packages/admin/admin-vite-plugin/src/custom-fields/helpers.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
CustomFieldModel,
|
||||
isValidCustomFieldModel,
|
||||
} from "@medusajs/admin-shared"
|
||||
import {
|
||||
ExportDefaultDeclaration,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isObjectProperty,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
NodePath,
|
||||
ObjectExpression,
|
||||
ObjectProperty,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
|
||||
export function getModel(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): CustomFieldModel | null {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "model" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!modelProperty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(modelProperty.value)) {
|
||||
logger.warn(
|
||||
`'model' property cannot be a template literal (e.g. \`product\`).`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isStringLiteral(modelProperty.value)) {
|
||||
logger.warn(
|
||||
`'model' is invalid. The 'model' property must be a string literal, e.g. 'product' or 'customer'.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const model = modelProperty.value.value.trim()
|
||||
|
||||
if (!isValidCustomFieldModel(model)) {
|
||||
logger.warn(
|
||||
`'model' is invalid, received: ${model}. The 'model' property must be set to a valid model, e.g. 'product' or 'customer'.`,
|
||||
{ file }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
export function getConfigArgument(
|
||||
path: NodePath<ExportDefaultDeclaration>
|
||||
): ObjectExpression | null {
|
||||
if (!isCallExpression(path.node.declaration)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!isIdentifier(path.node.declaration.callee, {
|
||||
name: "unstable_defineCustomFieldsConfig",
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const configArgument = path.node.declaration.arguments[0]
|
||||
|
||||
if (!isObjectExpression(configArgument)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return configArgument
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the 'link' property is present in the custom field config.
|
||||
* @param path - The NodePath to the export default declaration.
|
||||
* @param file - The file path.
|
||||
* @returns - True if the 'link' property is present, false otherwise.
|
||||
*/
|
||||
export function validateLink(
|
||||
path: NodePath<ExportDefaultDeclaration>,
|
||||
file: string
|
||||
): boolean {
|
||||
const configArgument = getConfigArgument(path)
|
||||
|
||||
if (!configArgument) {
|
||||
return false
|
||||
}
|
||||
|
||||
const linkProperty = configArgument.properties.find(
|
||||
(p) => isObjectProperty(p) && isIdentifier(p.key, { name: "link" })
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!linkProperty) {
|
||||
logger.warn(`'link' property is missing.`, { file })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./generate-custom-field-displays"
|
||||
export * from "./generate-custom-field-forms"
|
||||
export * from "./generate-custom-field-hashes"
|
||||
export * from "./generate-custom-field-links"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin"
|
||||
import { medusaVitePlugin } from "./plugin"
|
||||
import type { MedusaVitePlugin } from "./types"
|
||||
|
||||
export default medusaVitePlugin
|
||||
export type { MedusaVitePlugin }
|
||||
|
||||
64
packages/admin/admin-vite-plugin/src/logger.ts
Normal file
64
packages/admin/admin-vite-plugin/src/logger.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import colors from "picocolors"
|
||||
|
||||
type LoggerOptions = {
|
||||
file?: string | string[]
|
||||
error?: any
|
||||
}
|
||||
|
||||
function getTimestamp(): string {
|
||||
const now = new Date()
|
||||
return now.toLocaleTimeString("en-US", { hour12: true })
|
||||
}
|
||||
|
||||
function getPrefix(type: "warn" | "info" | "error") {
|
||||
const timestamp = colors.dim(getTimestamp())
|
||||
const typeColor =
|
||||
type === "warn"
|
||||
? colors.yellow
|
||||
: type === "info"
|
||||
? colors.green
|
||||
: colors.red
|
||||
|
||||
const prefix = typeColor("[@medusajs/admin-vite-plugin]")
|
||||
|
||||
return `${timestamp} ${prefix}`
|
||||
}
|
||||
|
||||
function getFile(options: LoggerOptions): string {
|
||||
if (!options.file) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const value = Array.isArray(options.file)
|
||||
? options.file.map((f) => f).join(", ")
|
||||
: options.file
|
||||
|
||||
return colors.dim(`${value}`)
|
||||
}
|
||||
|
||||
function formatError(error: any): string {
|
||||
if (error instanceof Error) {
|
||||
return colors.red(`${error.name}: ${error.message}\n${error.stack}`)
|
||||
} else if (typeof error === "object") {
|
||||
return colors.red(JSON.stringify(error, null, 2))
|
||||
} else {
|
||||
return colors.red(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const logger = {
|
||||
warn(msg: string, options: LoggerOptions = {}) {
|
||||
console.warn(`${getPrefix("warn")} ${msg} ${getFile(options)}`)
|
||||
},
|
||||
info(msg: string, options: LoggerOptions = {}) {
|
||||
console.info(`${getPrefix("info")} ${msg} ${getFile(options)}`)
|
||||
},
|
||||
error(msg: string, options: LoggerOptions = {}) {
|
||||
console.error(`${getPrefix("error")} ${msg} ${getFile(options)}`)
|
||||
if (options.error) {
|
||||
console.error(formatError(options.error))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export { logger }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import { isIdentifier, isObjectProperty, parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import { crawl, getConfigObjectProperties, getParserOptions } from "../utils"
|
||||
import { getRoute } from "./helpers"
|
||||
|
||||
type MenuItem = {
|
||||
icon?: string
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type MenuItemResult = {
|
||||
import: string
|
||||
menuItem: MenuItem
|
||||
}
|
||||
|
||||
export async function generateMenuItems(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getMenuItemResults(files)
|
||||
|
||||
const imports = results.map((result) => result.import).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(results: MenuItemResult[]): string {
|
||||
return outdent`
|
||||
menuItems: [
|
||||
${results
|
||||
.map((result) => formatMenuItem(result.menuItem))
|
||||
.join(",\n")}
|
||||
]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatMenuItem(route: MenuItem): string {
|
||||
return `{
|
||||
label: ${route.label},
|
||||
icon: ${route.icon ? route.icon : "undefined"},
|
||||
path: "${route.path}",
|
||||
}`
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
async function getMenuItemResults(files: string[]): Promise<MenuItemResult[]> {
|
||||
const results = await Promise.all(files.map(parseFile))
|
||||
return results.filter((item): item is MenuItemResult => item !== null)
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<MenuItemResult | null> {
|
||||
const config = await getRouteConfig(file)
|
||||
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!config.label) {
|
||||
logger.warn(`Config is missing a label.`, {
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
const menuItem = generateMenuItem(config, file, index)
|
||||
|
||||
return {
|
||||
import: import_,
|
||||
menuItem,
|
||||
}
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import { config as ${generateRouteConfigName(index)} } from "${file}"`
|
||||
}
|
||||
|
||||
function generateMenuItem(
|
||||
config: { label: boolean; icon: boolean },
|
||||
file: string,
|
||||
index: number
|
||||
): MenuItem {
|
||||
const configName = generateRouteConfigName(index)
|
||||
const routePath = getRoute(file)
|
||||
|
||||
return {
|
||||
label: `${configName}.label`,
|
||||
icon: config.icon ? `${configName}.icon` : undefined,
|
||||
path: routePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function getRouteConfig(
|
||||
file: string
|
||||
): Promise<{ label: boolean; icon: boolean } | null> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let config: { label: boolean; icon: boolean } | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
|
||||
if (!properties) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasLabel = properties.some(
|
||||
(prop) =>
|
||||
isObjectProperty(prop) && isIdentifier(prop.key, { name: "label" })
|
||||
)
|
||||
|
||||
if (!hasLabel) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasIcon = properties.some(
|
||||
(prop) =>
|
||||
isObjectProperty(prop) && isIdentifier(prop.key, { name: "icon" })
|
||||
)
|
||||
|
||||
config = { label: hasLabel, icon: hasIcon }
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function generateRouteConfigName(index: number): string {
|
||||
return `RouteConfig${index}`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises"
|
||||
import { parse, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
crawl,
|
||||
generateHash,
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
} from "../utils"
|
||||
|
||||
export async function generateRouteHashes(
|
||||
sources: Set<string>
|
||||
): Promise<{ defaultExportHash: string; configHash: string }> {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getRouteContents))
|
||||
|
||||
const defaultExportContents = contents
|
||||
.map((c) => c.defaultExport)
|
||||
.filter(Boolean)
|
||||
const configContents = contents.map((c) => c.config).filter(Boolean)
|
||||
|
||||
const totalDefaultExportContent = defaultExportContents.join("")
|
||||
const totalConfigContent = configContents.join("")
|
||||
|
||||
return {
|
||||
defaultExportHash: generateHash(totalDefaultExportContent),
|
||||
configHash: generateHash(totalConfigContent),
|
||||
}
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
async function getRouteContents(
|
||||
file: string
|
||||
): Promise<{ defaultExport: string | null; config: string | null }> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
let defaultExportContent: string | null = null
|
||||
let configContent: string | null = null
|
||||
|
||||
try {
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration(path) {
|
||||
defaultExportContent = code.slice(path.node.start!, path.node.end!)
|
||||
},
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (properties) {
|
||||
configContent = code.slice(path.node.start!, path.node.end!)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while processing ${file}. See the below error for more details:\n${e}`,
|
||||
{ file, error: e }
|
||||
)
|
||||
return { defaultExport: null, config: null }
|
||||
}
|
||||
|
||||
return { defaultExport: defaultExportContent, config: configContent }
|
||||
}
|
||||
155
packages/admin/admin-vite-plugin/src/routes/generate-routes.ts
Normal file
155
packages/admin/admin-vite-plugin/src/routes/generate-routes.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import fs from "fs/promises"
|
||||
import { outdent } from "outdent"
|
||||
import { parse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
crawl,
|
||||
getParserOptions,
|
||||
hasDefaultExport,
|
||||
normalizePath,
|
||||
} from "../utils"
|
||||
import { getRoute } from "./helpers"
|
||||
|
||||
type Route = {
|
||||
Component: string
|
||||
loader?: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type RouteResult = {
|
||||
imports: string[]
|
||||
route: Route
|
||||
}
|
||||
|
||||
export async function generateRoutes(sources: Set<string>) {
|
||||
const files = await getFilesFromSources(sources)
|
||||
const results = await getRouteResults(files)
|
||||
|
||||
const imports = results.map((result) => result.imports).flat()
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(results: RouteResult[]): string {
|
||||
return outdent`
|
||||
routes: [
|
||||
${results.map((result) => formatRoute(result.route)).join(",\n")}
|
||||
]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function formatRoute(route: Route): string {
|
||||
return `{
|
||||
Component: ${route.Component},
|
||||
loader: ${route.loader ? route.loader : "undefined"},
|
||||
path: "${route.path}",
|
||||
}`
|
||||
}
|
||||
|
||||
async function getFilesFromSources(sources: Set<string>): Promise<string[]> {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) =>
|
||||
crawl(`${source}/routes`, "page", { min: 1 })
|
||||
)
|
||||
)
|
||||
).flat()
|
||||
return files
|
||||
}
|
||||
|
||||
async function getRouteResults(files: string[]): Promise<RouteResult[]> {
|
||||
const results = (await Promise.all(files.map(parseFile))).filter(
|
||||
(result): result is RouteResult => result !== null
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<RouteResult | null> {
|
||||
if (!(await isValidRouteFile(file))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const loaderPath = await getLoader(file)
|
||||
const routePath = getRoute(file)
|
||||
|
||||
const imports = generateImports(file, loaderPath, index)
|
||||
const route = generateRoute(routePath, loaderPath, index)
|
||||
|
||||
return {
|
||||
imports,
|
||||
route,
|
||||
}
|
||||
}
|
||||
|
||||
async function isValidRouteFile(file: string): Promise<boolean> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
const ast = parse(code, getParserOptions(file))
|
||||
|
||||
try {
|
||||
return await hasDefaultExport(ast)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while checking for a default export in ${file}. The file will be ignored. See the below error for more details:\n${e}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getLoader(file: string): Promise<string | null> {
|
||||
const loaderExtensions = ["ts", "js", "tsx", "jsx"]
|
||||
for (const ext of loaderExtensions) {
|
||||
const loaderPath = file.replace(/\/page\.(tsx|jsx)/, `/loader.${ext}`)
|
||||
const exists = await fs.stat(loaderPath).catch(() => null)
|
||||
if (exists) {
|
||||
return loaderPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateImports(
|
||||
file: string,
|
||||
loader: string | null,
|
||||
index: number
|
||||
): string[] {
|
||||
const imports: string[] = []
|
||||
const route = generateRouteComponentName(index)
|
||||
const importPath = normalizePath(file)
|
||||
|
||||
imports.push(`import ${route} from "${importPath}"`)
|
||||
|
||||
if (loader) {
|
||||
const loaderName = generateRouteLoaderName(index)
|
||||
imports.push(`import ${loaderName} from "${normalizePath(loader)}"`)
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
|
||||
function generateRoute(
|
||||
route: string,
|
||||
loader: string | null,
|
||||
index: number
|
||||
): Route {
|
||||
return {
|
||||
Component: generateRouteComponentName(index),
|
||||
loader: loader ? generateRouteLoaderName(index) : undefined,
|
||||
path: route,
|
||||
}
|
||||
}
|
||||
|
||||
function generateRouteComponentName(index: number): string {
|
||||
return `RouteComponent${index}`
|
||||
}
|
||||
|
||||
function generateRouteLoaderName(index: number): string {
|
||||
return `RouteLoader${index}`
|
||||
}
|
||||
9
packages/admin/admin-vite-plugin/src/routes/helpers.ts
Normal file
9
packages/admin/admin-vite-plugin/src/routes/helpers.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { normalizePath } from "../utils"
|
||||
|
||||
export function getRoute(file: string): string {
|
||||
const importPath = normalizePath(file)
|
||||
return importPath
|
||||
.replace(/.*\/admin\/(routes)/, "")
|
||||
.replace(/\[([^\]]+)\]/g, ":$1")
|
||||
.replace(/\/page\.(tsx|jsx)/, "")
|
||||
}
|
||||
3
packages/admin/admin-vite-plugin/src/routes/index.ts
Normal file
3
packages/admin/admin-vite-plugin/src/routes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./generate-menu-items"
|
||||
export * from "./generate-route-hashes"
|
||||
export * from "./generate-routes"
|
||||
61
packages/admin/admin-vite-plugin/src/types.ts
Normal file
61
packages/admin/admin-vite-plugin/src/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
CustomFieldFormTab,
|
||||
CustomFieldModel,
|
||||
CustomFieldZone,
|
||||
InjectionZone,
|
||||
} from "@medusajs/admin-shared"
|
||||
import type * as Vite from "vite"
|
||||
|
||||
export type ExtensionGraph = Map<string, Set<string>>
|
||||
|
||||
export type CustomFieldLinkPath = {
|
||||
model: CustomFieldModel
|
||||
}
|
||||
|
||||
export type CustomFieldDisplayPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
}
|
||||
|
||||
export type CustomFieldConfigPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
}
|
||||
|
||||
export type CustomFieldFieldPath = {
|
||||
model: CustomFieldModel
|
||||
zone: CustomFieldZone
|
||||
tab?: CustomFieldFormTab
|
||||
}
|
||||
|
||||
export type LoadModuleOptions =
|
||||
| {
|
||||
type: "widget"
|
||||
get: InjectionZone
|
||||
}
|
||||
| {
|
||||
type: "route"
|
||||
get: "page" | "link"
|
||||
}
|
||||
| {
|
||||
type: "link"
|
||||
get: CustomFieldLinkPath
|
||||
}
|
||||
| {
|
||||
type: "field"
|
||||
get: CustomFieldFieldPath
|
||||
}
|
||||
| {
|
||||
type: "config"
|
||||
get: CustomFieldConfigPath
|
||||
}
|
||||
| {
|
||||
type: "display"
|
||||
get: CustomFieldDisplayPath
|
||||
}
|
||||
|
||||
export interface MedusaVitePluginOptions {
|
||||
sources?: string[]
|
||||
}
|
||||
|
||||
export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin
|
||||
147
packages/admin/admin-vite-plugin/src/utils.ts
Normal file
147
packages/admin/admin-vite-plugin/src/utils.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { fdir } from "fdir"
|
||||
import MagicString from "magic-string"
|
||||
import crypto from "node:crypto"
|
||||
import path from "path"
|
||||
import {
|
||||
File,
|
||||
isCallExpression,
|
||||
isIdentifier,
|
||||
isObjectExpression,
|
||||
isVariableDeclaration,
|
||||
isVariableDeclarator,
|
||||
ParseResult,
|
||||
traverse,
|
||||
type ExportNamedDeclaration,
|
||||
type NodePath,
|
||||
type ParserOptions,
|
||||
} from "./babel"
|
||||
|
||||
export function normalizePath(file: string) {
|
||||
return path.normalize(file).split(path.sep).join("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parser options for a given file.
|
||||
*/
|
||||
export 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
|
||||
*/
|
||||
export function generateModule(code: string) {
|
||||
const magicString = new MagicString(code)
|
||||
|
||||
return {
|
||||
code: magicString.toString(),
|
||||
map: magicString.generateMap({ hires: true }),
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"]
|
||||
|
||||
/**
|
||||
* Crawls a directory and returns all files that match the criteria.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export function getConfigObjectProperties(
|
||||
path: NodePath<ExportNamedDeclaration>
|
||||
) {
|
||||
const declaration = path.node.declaration
|
||||
|
||||
if (isVariableDeclaration(declaration)) {
|
||||
const configDeclaration = declaration.declarations.find(
|
||||
(d) => isVariableDeclarator(d) && isIdentifier(d.id, { name: "config" })
|
||||
)
|
||||
|
||||
if (
|
||||
configDeclaration &&
|
||||
isCallExpression(configDeclaration.init) &&
|
||||
configDeclaration.init.arguments.length > 0 &&
|
||||
isObjectExpression(configDeclaration.init.arguments[0])
|
||||
) {
|
||||
return configDeclaration.init.arguments[0].properties
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function hasDefaultExport(
|
||||
ast: ParseResult<File>
|
||||
): Promise<boolean> {
|
||||
let hasDefaultExport = false
|
||||
traverse(ast, {
|
||||
ExportDefaultDeclaration() {
|
||||
hasDefaultExport = true
|
||||
},
|
||||
})
|
||||
return hasDefaultExport
|
||||
}
|
||||
|
||||
export function generateHash(content: string) {
|
||||
return crypto.createHash("md5").update(content).digest("hex")
|
||||
}
|
||||
|
||||
const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets"] as const
|
||||
|
||||
export type AdminSubdirectory = (typeof ADMIN_SUBDIRECTORIES)[number]
|
||||
|
||||
export function isFileInAdminSubdirectory(
|
||||
file: string,
|
||||
subdirectory: AdminSubdirectory
|
||||
): boolean {
|
||||
const normalizedPath = normalizePath(file)
|
||||
return normalizedPath.includes(`/src/admin/${subdirectory}/`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateCustomFieldDisplays } from "../custom-fields"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualDisplayModule(sources: Set<string>) {
|
||||
const displays = await generateCustomFieldDisplays(sources)
|
||||
|
||||
const code = outdent`
|
||||
${displays.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${displays.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import outdent from "outdent"
|
||||
import { generateCustomFieldForms } from "../custom-fields"
|
||||
import { generateMenuItems } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
import { generateWidgets } from "../widgets"
|
||||
|
||||
export async function generateVirtualFormModule(sources: Set<string>) {
|
||||
const menuItems = await generateMenuItems(sources)
|
||||
const widgets = await generateWidgets(sources)
|
||||
const customFields = await generateCustomFieldForms(sources)
|
||||
|
||||
const imports = [
|
||||
...menuItems.imports,
|
||||
...widgets.imports,
|
||||
...customFields.imports,
|
||||
]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${menuItems.code},
|
||||
${widgets.code},
|
||||
${customFields.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateCustomFieldLinks } from "../custom-fields"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualLinkModule(sources: Set<string>) {
|
||||
const links = await generateCustomFieldLinks(sources)
|
||||
|
||||
const code = outdent`
|
||||
${links.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${links.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import outdent from "outdent"
|
||||
|
||||
import { generateMenuItems } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualMenuItemModule(sources: Set<string>) {
|
||||
const menuItems = await generateMenuItems(sources)
|
||||
|
||||
const code = outdent`
|
||||
${menuItems.imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${menuItems.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { outdent } from "outdent"
|
||||
import { generateRoutes } from "../routes"
|
||||
import { generateModule } from "../utils"
|
||||
|
||||
export async function generateVirtualRouteModule(sources: Set<string>) {
|
||||
const routes = await generateRoutes(sources)
|
||||
|
||||
const imports = [...routes.imports]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${routes.code}
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import outdent from "outdent"
|
||||
import { generateModule } from "../utils"
|
||||
import { generateWidgets } from "../widgets"
|
||||
|
||||
export async function generateVirtualWidgetModule(sources: Set<string>) {
|
||||
const widgets = await generateWidgets(sources)
|
||||
|
||||
const imports = [...widgets.imports]
|
||||
|
||||
const code = outdent`
|
||||
${imports.join("\n")}
|
||||
|
||||
export default {
|
||||
${widgets.code},
|
||||
}
|
||||
`
|
||||
|
||||
return generateModule(code)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./generate-virtual-display-module"
|
||||
export * from "./generate-virtual-form-module"
|
||||
export * from "./generate-virtual-link-module"
|
||||
export * from "./generate-virtual-menu-item-module"
|
||||
export * from "./generate-virtual-route-module"
|
||||
export * from "./generate-virtual-widget-module"
|
||||
80
packages/admin/admin-vite-plugin/src/vmod.ts
Normal file
80
packages/admin/admin-vite-plugin/src/vmod.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DISPLAY_VIRTUAL_MODULE,
|
||||
FORM_VIRTUAL_MODULE,
|
||||
LINK_VIRTUAL_MODULE,
|
||||
MENU_ITEM_VIRTUAL_MODULE,
|
||||
ROUTE_VIRTUAL_MODULE,
|
||||
WIDGET_VIRTUAL_MODULE,
|
||||
} from "@medusajs/admin-shared"
|
||||
|
||||
const RESOLVED_LINK_VIRTUAL_MODULE = `\0${LINK_VIRTUAL_MODULE}`
|
||||
const RESOLVED_FORM_VIRTUAL_MODULE = `\0${FORM_VIRTUAL_MODULE}`
|
||||
const RESOLVED_DISPLAY_VIRTUAL_MODULE = `\0${DISPLAY_VIRTUAL_MODULE}`
|
||||
const RESOLVED_ROUTE_VIRTUAL_MODULE = `\0${ROUTE_VIRTUAL_MODULE}`
|
||||
const RESOLVED_MENU_ITEM_VIRTUAL_MODULE = `\0${MENU_ITEM_VIRTUAL_MODULE}`
|
||||
const RESOLVED_WIDGET_VIRTUAL_MODULE = `\0${WIDGET_VIRTUAL_MODULE}`
|
||||
|
||||
const VIRTUAL_MODULES = [
|
||||
LINK_VIRTUAL_MODULE,
|
||||
FORM_VIRTUAL_MODULE,
|
||||
DISPLAY_VIRTUAL_MODULE,
|
||||
ROUTE_VIRTUAL_MODULE,
|
||||
MENU_ITEM_VIRTUAL_MODULE,
|
||||
WIDGET_VIRTUAL_MODULE,
|
||||
] as const
|
||||
|
||||
const RESOLVED_VIRTUAL_MODULES = [
|
||||
RESOLVED_LINK_VIRTUAL_MODULE,
|
||||
RESOLVED_FORM_VIRTUAL_MODULE,
|
||||
RESOLVED_DISPLAY_VIRTUAL_MODULE,
|
||||
RESOLVED_ROUTE_VIRTUAL_MODULE,
|
||||
RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
|
||||
RESOLVED_WIDGET_VIRTUAL_MODULE,
|
||||
] as const
|
||||
|
||||
export function resolveVirtualId(id: string) {
|
||||
return `\0${id}`
|
||||
}
|
||||
|
||||
export function isVirtualModuleId(id: string): id is VirtualModule {
|
||||
return VIRTUAL_MODULES.includes(id as VirtualModule)
|
||||
}
|
||||
|
||||
export function isResolvedVirtualModuleId(
|
||||
id: string
|
||||
): id is (typeof RESOLVED_VIRTUAL_MODULES)[number] {
|
||||
return RESOLVED_VIRTUAL_MODULES.includes(
|
||||
id as (typeof RESOLVED_VIRTUAL_MODULES)[number]
|
||||
)
|
||||
}
|
||||
|
||||
export type VirtualModule =
|
||||
| typeof LINK_VIRTUAL_MODULE
|
||||
| typeof FORM_VIRTUAL_MODULE
|
||||
| typeof DISPLAY_VIRTUAL_MODULE
|
||||
| typeof ROUTE_VIRTUAL_MODULE
|
||||
| typeof MENU_ITEM_VIRTUAL_MODULE
|
||||
| typeof WIDGET_VIRTUAL_MODULE
|
||||
|
||||
const resolvedVirtualModuleIds = {
|
||||
link: RESOLVED_LINK_VIRTUAL_MODULE,
|
||||
form: RESOLVED_FORM_VIRTUAL_MODULE,
|
||||
display: RESOLVED_DISPLAY_VIRTUAL_MODULE,
|
||||
route: RESOLVED_ROUTE_VIRTUAL_MODULE,
|
||||
menuItem: RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
|
||||
widget: RESOLVED_WIDGET_VIRTUAL_MODULE,
|
||||
} as const
|
||||
|
||||
const virtualModuleIds = {
|
||||
link: LINK_VIRTUAL_MODULE,
|
||||
form: FORM_VIRTUAL_MODULE,
|
||||
display: DISPLAY_VIRTUAL_MODULE,
|
||||
route: ROUTE_VIRTUAL_MODULE,
|
||||
menuItem: MENU_ITEM_VIRTUAL_MODULE,
|
||||
widget: WIDGET_VIRTUAL_MODULE,
|
||||
} as const
|
||||
|
||||
export const vmod = {
|
||||
resolved: resolvedVirtualModuleIds,
|
||||
virtual: virtualModuleIds,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "fs/promises"
|
||||
import { File, parse, ParseResult, traverse } from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
generateHash,
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
} from "../utils"
|
||||
import { getWidgetFilesFromSources } from "./helpers"
|
||||
|
||||
export async function generateWidgetHash(
|
||||
sources: Set<string>
|
||||
): Promise<string> {
|
||||
const files = await getWidgetFilesFromSources(sources)
|
||||
const contents = await Promise.all(files.map(getWidgetContents))
|
||||
const totalContent = contents
|
||||
.flatMap(({ config, defaultExport }) => [config, defaultExport])
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
|
||||
return generateHash(totalContent)
|
||||
}
|
||||
|
||||
async function getWidgetContents(
|
||||
file: string
|
||||
): Promise<{ config: string | null; defaultExport: string | null }> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(code, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`An error occurred while parsing the file. Due to the error we cannot validate whether the widget has changed. If your changes aren't correctly reflected try restarting the dev server.`,
|
||||
{
|
||||
file,
|
||||
error: e,
|
||||
}
|
||||
)
|
||||
return { config: null, defaultExport: null }
|
||||
}
|
||||
|
||||
let configContent: string | null = null
|
||||
let defaultExportContent: string | null = null
|
||||
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (properties) {
|
||||
configContent = code.slice(path.node.start!, path.node.end!)
|
||||
}
|
||||
},
|
||||
ExportDefaultDeclaration(path) {
|
||||
defaultExportContent = code.slice(path.node.start!, path.node.end!)
|
||||
},
|
||||
})
|
||||
|
||||
return { config: configContent, defaultExport: defaultExportContent }
|
||||
}
|
||||
203
packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts
Normal file
203
packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { InjectionZone, isValidInjectionZone } from "@medusajs/admin-shared"
|
||||
import fs from "fs/promises"
|
||||
import outdent from "outdent"
|
||||
import {
|
||||
File,
|
||||
isArrayExpression,
|
||||
isStringLiteral,
|
||||
isTemplateLiteral,
|
||||
ObjectProperty,
|
||||
parse,
|
||||
ParseResult,
|
||||
traverse,
|
||||
} from "../babel"
|
||||
import { logger } from "../logger"
|
||||
import {
|
||||
getConfigObjectProperties,
|
||||
getParserOptions,
|
||||
hasDefaultExport,
|
||||
} from "../utils"
|
||||
import { getWidgetFilesFromSources } from "./helpers"
|
||||
|
||||
type WidgetConfig = {
|
||||
Component: string
|
||||
zone: InjectionZone[]
|
||||
}
|
||||
|
||||
type ParsedWidgetConfig = {
|
||||
import: string
|
||||
widget: WidgetConfig
|
||||
}
|
||||
|
||||
export async function generateWidgets(sources: Set<string>) {
|
||||
const files = await getWidgetFilesFromSources(sources)
|
||||
const results = await getWidgetResults(files)
|
||||
|
||||
const imports = results.map((r) => r.import)
|
||||
const code = generateCode(results)
|
||||
|
||||
return {
|
||||
imports,
|
||||
code,
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetResults(
|
||||
files: string[]
|
||||
): Promise<ParsedWidgetConfig[]> {
|
||||
return (await Promise.all(files.map(parseFile))).filter(
|
||||
(r) => r !== null
|
||||
) as ParsedWidgetConfig[]
|
||||
}
|
||||
|
||||
function generateCode(results: ParsedWidgetConfig[]): string {
|
||||
return outdent`
|
||||
widgets: [
|
||||
${results.map((r) => formatWidget(r.widget)).join(",\n")}
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
function formatWidget(widget: WidgetConfig): string {
|
||||
return outdent`
|
||||
{
|
||||
Component: ${widget.Component},
|
||||
zone: [${widget.zone.map((z) => `"${z}"`).join(", ")}]
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function parseFile(
|
||||
file: string,
|
||||
index: number
|
||||
): Promise<ParsedWidgetConfig | null> {
|
||||
const code = await fs.readFile(file, "utf-8")
|
||||
let ast: ParseResult<File>
|
||||
|
||||
try {
|
||||
ast = parse(code, getParserOptions(file))
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while parsing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
let fileHasDefaultExport = false
|
||||
|
||||
try {
|
||||
fileHasDefaultExport = await hasDefaultExport(ast)
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while checking for a default export.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!fileHasDefaultExport) {
|
||||
return null
|
||||
}
|
||||
|
||||
let zone: InjectionZone[] | null
|
||||
|
||||
try {
|
||||
zone = await getWidgetZone(ast, file)
|
||||
} catch (e) {
|
||||
logger.error(`An error occurred while traversing the file.`, {
|
||||
file,
|
||||
error: e,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!zone) {
|
||||
logger.warn(`'zone' property is missing from the widget config.`, { file })
|
||||
return null
|
||||
}
|
||||
|
||||
const import_ = generateImport(file, index)
|
||||
const widget = generateWidget(zone, index)
|
||||
|
||||
return {
|
||||
widget,
|
||||
import: import_,
|
||||
}
|
||||
}
|
||||
|
||||
function generateWidgetComponentName(index: number): string {
|
||||
return `WidgetComponent${index}`
|
||||
}
|
||||
|
||||
function generateWidgetConfigName(index: number): string {
|
||||
return `WidgetConfig${index}`
|
||||
}
|
||||
|
||||
function generateImport(file: string, index: number): string {
|
||||
return `import ${generateWidgetComponentName(
|
||||
index
|
||||
)}, { config as ${generateWidgetConfigName(index)} } from "${file}"`
|
||||
}
|
||||
|
||||
function generateWidget(zone: InjectionZone[], index: number): WidgetConfig {
|
||||
return {
|
||||
Component: generateWidgetComponentName(index),
|
||||
zone: zone,
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetZone(
|
||||
ast: ParseResult<File>,
|
||||
file: string
|
||||
): Promise<InjectionZone[] | null> {
|
||||
const zones: string[] = []
|
||||
|
||||
traverse(ast, {
|
||||
ExportNamedDeclaration(path) {
|
||||
const properties = getConfigObjectProperties(path)
|
||||
if (!properties) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoneProperty = properties.find(
|
||||
(p) =>
|
||||
p.type === "ObjectProperty" &&
|
||||
p.key.type === "Identifier" &&
|
||||
p.key.name === "zone"
|
||||
) as ObjectProperty | undefined
|
||||
|
||||
if (!zoneProperty) {
|
||||
logger.warn(`'zone' property is missing from the widget config.`, {
|
||||
file,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateLiteral(zoneProperty.value)) {
|
||||
logger.warn(
|
||||
`'zone' property cannot be a template literal (e.g. \`product.details.after\`).`,
|
||||
{ file }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isStringLiteral(zoneProperty.value)) {
|
||||
zones.push(zoneProperty.value.value)
|
||||
} else if (isArrayExpression(zoneProperty.value)) {
|
||||
const values: string[] = []
|
||||
|
||||
for (const element of zoneProperty.value.elements) {
|
||||
if (isStringLiteral(element)) {
|
||||
values.push(element.value)
|
||||
}
|
||||
}
|
||||
|
||||
zones.push(...values)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validatedZones = zones.filter(isValidInjectionZone)
|
||||
return validatedZones.length > 0 ? validatedZones : null
|
||||
}
|
||||
11
packages/admin/admin-vite-plugin/src/widgets/helpers.ts
Normal file
11
packages/admin/admin-vite-plugin/src/widgets/helpers.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { crawl } from "../utils"
|
||||
|
||||
export async function getWidgetFilesFromSources(
|
||||
sources: Set<string>
|
||||
): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
Array.from(sources).map(async (source) => crawl(`${source}/widgets`))
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
2
packages/admin/admin-vite-plugin/src/widgets/index.ts
Normal file
2
packages/admin/admin-vite-plugin/src/widgets/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./generate-widget-hash"
|
||||
export * from "./generate-widgets"
|
||||
Reference in New Issue
Block a user