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, type VariableDeclarator, } from "./babel" export function normalizePath(file: string) { return path.normalize(file).replace(/\\/g, "/") } /** * 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 }), } } export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js"] /** * 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 ) { if (isVariableDeclarator(path.node)) { const configDeclaration = isIdentifier(path.node.id, { name: "config" }) ? path.node : null if ( configDeclaration && isCallExpression(configDeclaration.init) && configDeclaration.init.arguments.length > 0 && isObjectExpression(configDeclaration.init.arguments[0]) ) { return configDeclaration.init.arguments[0].properties } return null } 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 ): Promise { let hasDefaultExport = false traverse(ast, { ExportDefaultDeclaration() { hasDefaultExport = true }, AssignmentExpression(path) { if ( path.node.left.type === "MemberExpression" && path.node.left.object.type === "Identifier" && path.node.left.object.name === "exports" && path.node.left.property.type === "Identifier" && path.node.left.property.name === "default" ) { hasDefaultExport = true } }, ExportNamedDeclaration(path) { const specifiers = path.node.specifiers if ( specifiers?.some( (s) => s.type === "ExportSpecifier" && s.exported.type === "Identifier" && s.exported.name === "default" ) ) { 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}/`) } /** * Test util to normalize strings, so they can be compared without taking * whitespace into account. */ export function normalizeString(str: string): string { return str.replace(/\s+/g, " ").trim() }