Files
medusa-store/packages/admin/admin-vite-plugin/src/utils.ts
Leonardo Benini 226984cf0f feat(admin-*,dashboard): add dashboard i18n extensions (#13763)
* virtual i18n module

* changeset

* fallback ns

fallback to the default "translation" ns if the key isnt found. Allows to use a single "useTranslation("customNs")" hook for both custom and medusa-provided keys

* simplify merges

* optional for backward compat

* fix HMR

* fix generated deepMerge

* test
2025-10-23 15:16:43 -04:00

198 lines
4.8 KiB
TypeScript

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", ".ts"]
/**
* 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 | VariableDeclarator>
) {
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<File>
): Promise<boolean> {
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", "i18n"] 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()
}