Simplify config file script in sdk (#17267)
Some simplifications about loading the manifest as there is no difference between all entities
This commit is contained in:
@@ -1,269 +0,0 @@
|
||||
import type { FrontComponentConfig, FunctionConfig } from '@/application';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createJiti } from 'jiti';
|
||||
import { type JitiOptions } from 'jiti/lib/types';
|
||||
import path from 'path';
|
||||
import {
|
||||
type FrontComponentManifest,
|
||||
type ServerlessFunctionManifest,
|
||||
} from 'twenty-shared/application';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parseJsoncFile } from './file-jsonc';
|
||||
|
||||
type ExtractOptions = {
|
||||
propertyName: string;
|
||||
entityType: string;
|
||||
jsx?: boolean;
|
||||
};
|
||||
|
||||
const getTsconfigAliases = async (
|
||||
appPath: string,
|
||||
): Promise<Record<string, string>> => {
|
||||
const tsconfigPath = path.join(appPath, 'tsconfig.json');
|
||||
|
||||
if (!(await fs.pathExists(tsconfigPath))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const tsconfig = await parseJsoncFile(tsconfigPath);
|
||||
const paths = tsconfig?.compilerOptions?.paths as
|
||||
| Record<string, string[]>
|
||||
| undefined;
|
||||
const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.';
|
||||
|
||||
if (!paths) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const aliases: Record<string, string> = {};
|
||||
|
||||
for (const [pattern, targets] of Object.entries(paths)) {
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const aliasKey = pattern.replace(/\/\*$/, '');
|
||||
const targetPath = targets[0].replace(/\/\*$/, '');
|
||||
const resolvedTarget = path.resolve(appPath, baseUrl, targetPath);
|
||||
aliases[aliasKey] = resolvedTarget;
|
||||
}
|
||||
|
||||
return aliases;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const createConfigLoader = async (
|
||||
appPath?: string,
|
||||
options: { jsx?: boolean } = {},
|
||||
) => {
|
||||
const basePath = appPath ?? fileURLToPath(import.meta.url);
|
||||
|
||||
const jitiOptions: JitiOptions = {
|
||||
moduleCache: false,
|
||||
fsCache: false,
|
||||
interopDefault: true,
|
||||
};
|
||||
|
||||
if (options.jsx) {
|
||||
jitiOptions.jsx = { runtime: 'automatic' };
|
||||
}
|
||||
|
||||
if (appPath) {
|
||||
const aliases = await getTsconfigAliases(appPath);
|
||||
if (Object.keys(aliases).length > 0) {
|
||||
jitiOptions.alias = aliases;
|
||||
}
|
||||
}
|
||||
|
||||
return createJiti(basePath, jitiOptions);
|
||||
};
|
||||
|
||||
const findConfigExport = <T>(
|
||||
mod: Record<string, unknown>,
|
||||
validator?: (value: unknown) => boolean,
|
||||
): T | undefined => {
|
||||
if (mod.default !== undefined) {
|
||||
if (!validator || validator(mod.default)) {
|
||||
return mod.default as T;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(mod)) {
|
||||
if (key === 'default') continue;
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value !== 'object') continue;
|
||||
if (Array.isArray(value)) continue;
|
||||
|
||||
if (!validator || validator(value)) {
|
||||
return value as T;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const loadConfig = async <T>(
|
||||
filepath: string,
|
||||
appPath?: string,
|
||||
): Promise<T> => {
|
||||
const jiti = await createConfigLoader(appPath);
|
||||
|
||||
try {
|
||||
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
|
||||
const config = findConfigExport<T>(mod);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Config file ${filepath} must export a config object (default export or any named object export)`,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(
|
||||
`Failed to load config from ${filepath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
const extractImportPath = (
|
||||
source: string,
|
||||
identifier: string,
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
): string | null => {
|
||||
const escapedIdentifier = escapeRegExp(identifier);
|
||||
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
),
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
),
|
||||
new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = source.match(pattern);
|
||||
|
||||
if (match) {
|
||||
const importPath = match[1];
|
||||
const fileDir = path.dirname(filepath);
|
||||
const absolutePath = path.resolve(fileDir, importPath);
|
||||
const relativePath = path.relative(appPath, absolutePath);
|
||||
|
||||
const resultPath = relativePath.endsWith('.ts')
|
||||
? relativePath
|
||||
: `${relativePath}.ts`;
|
||||
|
||||
return resultPath.replace(/\\/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractConfigFromFile = async <T extends Record<string, unknown>>(
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
options: ExtractOptions,
|
||||
): Promise<{ config: T; entryName: string; entryPath: string }> => {
|
||||
const { propertyName, entityType, jsx } = options;
|
||||
const jiti = await createConfigLoader(appPath, { jsx });
|
||||
|
||||
try {
|
||||
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
|
||||
|
||||
const hasProperty = (value: unknown): boolean => {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
propertyName in value &&
|
||||
typeof (value as Record<string, unknown>)[propertyName] === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
const config = findConfigExport<T & Record<string, Function>>(
|
||||
mod,
|
||||
hasProperty,
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`${entityType} file ${filepath} must export a config object with a "${propertyName}" property`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryName = config[propertyName].name;
|
||||
|
||||
if (!entryName) {
|
||||
throw new Error(
|
||||
`${propertyName} function in ${filepath} must be a named function`,
|
||||
);
|
||||
}
|
||||
|
||||
const source = await fs.readFile(filepath, 'utf8');
|
||||
const importPath = extractImportPath(source, entryName, filepath, appPath);
|
||||
const entryPath =
|
||||
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
|
||||
|
||||
return { config, entryName, entryPath };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(
|
||||
`Failed to load ${entityType.toLowerCase()} module from ${filepath}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const extractFunctionConfig = async (
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
): Promise<ServerlessFunctionManifest> => {
|
||||
const { config, entryName, entryPath } =
|
||||
await extractConfigFromFile<FunctionConfig>(filepath, appPath, {
|
||||
propertyName: 'handler',
|
||||
entityType: 'Function',
|
||||
});
|
||||
|
||||
return {
|
||||
universalIdentifier: config.universalIdentifier,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
timeoutSeconds: config.timeoutSeconds,
|
||||
triggers: config.triggers ?? [],
|
||||
handlerName: entryName,
|
||||
handlerPath: entryPath,
|
||||
};
|
||||
};
|
||||
|
||||
export const extractFrontComponentConfig = async (
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
): Promise<FrontComponentManifest> => {
|
||||
const { config, entryName, entryPath } =
|
||||
await extractConfigFromFile<FrontComponentConfig>(filepath, appPath, {
|
||||
propertyName: 'component',
|
||||
entityType: 'Front component',
|
||||
jsx: true,
|
||||
});
|
||||
|
||||
return {
|
||||
universalIdentifier: config.universalIdentifier,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
componentName: entryName,
|
||||
componentPath: entryPath,
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,8 @@
|
||||
import { type RoleConfig } from '@/application/role-config';
|
||||
import {
|
||||
extractFrontComponentConfig,
|
||||
extractFunctionConfig,
|
||||
loadConfig,
|
||||
} from '@/cli/utilities/file/utils/file-config-loader';
|
||||
import { findPathFile } from '@/cli/utilities/file/utils/file-find';
|
||||
import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc';
|
||||
import {
|
||||
parseJsoncFile,
|
||||
parseTextFile,
|
||||
} from '@/cli/utilities/file/utils/file-jsonc';
|
||||
import { glob } from 'fast-glob';
|
||||
import * as fs from 'fs-extra';
|
||||
import path, { posix, relative, sep } from 'path';
|
||||
@@ -24,11 +21,9 @@ import {
|
||||
ManifestValidationError,
|
||||
type ValidationWarning,
|
||||
} from '../types/manifest.types';
|
||||
import { extractManifestFromFile } from './manifest-file-extractor';
|
||||
import { validateManifest } from './manifest-validate';
|
||||
|
||||
/**
|
||||
* Validate that the required folder structure exists.
|
||||
*/
|
||||
const validateFolderStructure = async (appPath: string): Promise<void> => {
|
||||
const appFolder = path.join(appPath, 'src', 'app');
|
||||
|
||||
@@ -45,9 +40,6 @@ const validateFolderStructure = async (appPath: string): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a file path to posix format relative to appPath.
|
||||
*/
|
||||
const toPosixRelative = (filepath: string, appPath: string): string => {
|
||||
const rel = relative(appPath, filepath);
|
||||
return rel.split(sep).join(posix.sep);
|
||||
@@ -64,19 +56,18 @@ const loadFiles = async (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all object definitions from src/app/ (any *.object.ts file).
|
||||
*/
|
||||
const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
|
||||
const loadObjectManifests = async (
|
||||
appPath: string,
|
||||
): Promise<ObjectManifest[]> => {
|
||||
const objectFiles = await loadFiles(['src/app/**/*.object.ts'], appPath);
|
||||
|
||||
const objects: ObjectManifest[] = [];
|
||||
const objectManifests: ObjectManifest[] = [];
|
||||
|
||||
for (const filepath of objectFiles) {
|
||||
try {
|
||||
const manifest = await loadConfig<ObjectManifest>(filepath, appPath);
|
||||
|
||||
objects.push(manifest);
|
||||
objectManifests.push(
|
||||
await extractManifestFromFile<ObjectManifest>(filepath, appPath),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
@@ -85,13 +76,10 @@ const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
return objectManifests;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all object extension definitions from src/app/ (any *.object-extension.ts file).
|
||||
*/
|
||||
const loadObjectExtensions = async (
|
||||
const loadObjectExtensionManifests = async (
|
||||
appPath: string,
|
||||
): Promise<ObjectExtensionManifest[]> => {
|
||||
const extensionFiles = await loadFiles(
|
||||
@@ -99,13 +87,13 @@ const loadObjectExtensions = async (
|
||||
appPath,
|
||||
);
|
||||
|
||||
const extensions: ObjectExtensionManifest[] = [];
|
||||
const objectExtensionManifests: ObjectExtensionManifest[] = [];
|
||||
|
||||
for (const filepath of extensionFiles) {
|
||||
try {
|
||||
const manifest = await loadConfig<ObjectExtensionManifest>(filepath, appPath);
|
||||
|
||||
extensions.push(manifest);
|
||||
objectExtensionManifests.push(
|
||||
await extractManifestFromFile<ObjectExtensionManifest>(filepath, appPath),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
@@ -114,19 +102,25 @@ const loadObjectExtensions = async (
|
||||
}
|
||||
}
|
||||
|
||||
return extensions;
|
||||
return objectExtensionManifests;
|
||||
};
|
||||
|
||||
const loadFunctions = async (
|
||||
const loadFunctionManifests = async (
|
||||
appPath: string,
|
||||
): Promise<ServerlessFunctionManifest[]> => {
|
||||
const functionFiles = await loadFiles(['src/app/**/*.function.ts'], appPath);
|
||||
|
||||
const functions: ServerlessFunctionManifest[] = [];
|
||||
const functionManifests: ServerlessFunctionManifest[] = [];
|
||||
|
||||
for (const filepath of functionFiles) {
|
||||
try {
|
||||
functions.push(await extractFunctionConfig(filepath, appPath));
|
||||
functionManifests.push(
|
||||
await extractManifestFromFile<ServerlessFunctionManifest>(
|
||||
filepath,
|
||||
appPath,
|
||||
{ entryProperty: 'handler' },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
@@ -135,21 +129,19 @@ const loadFunctions = async (
|
||||
}
|
||||
}
|
||||
|
||||
return functions;
|
||||
return functionManifests;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all role definitions from src/app/ (any *.role.ts file).
|
||||
*/
|
||||
const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
|
||||
const loadRoleManifests = async (appPath: string): Promise<RoleManifest[]> => {
|
||||
const roleFiles = await loadFiles(['src/app/**/*.role.ts'], appPath);
|
||||
|
||||
const roles: RoleManifest[] = [];
|
||||
const roleManifests: RoleManifest[] = [];
|
||||
|
||||
for (const filepath of roleFiles) {
|
||||
try {
|
||||
const config = await loadConfig<RoleConfig>(filepath, appPath);
|
||||
roles.push(config);
|
||||
roleManifests.push(
|
||||
await extractManifestFromFile<RoleManifest>(filepath, appPath),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
@@ -158,10 +150,10 @@ const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
return roles;
|
||||
return roleManifests;
|
||||
};
|
||||
|
||||
const loadFrontComponents = async (
|
||||
const loadFrontComponentManifests = async (
|
||||
appPath: string,
|
||||
): Promise<FrontComponentManifest[]> => {
|
||||
const componentFiles = await loadFiles(
|
||||
@@ -169,11 +161,17 @@ const loadFrontComponents = async (
|
||||
appPath,
|
||||
);
|
||||
|
||||
const components: FrontComponentManifest[] = [];
|
||||
const frontComponentManifests: FrontComponentManifest[] = [];
|
||||
|
||||
for (const filepath of componentFiles) {
|
||||
try {
|
||||
components.push(await extractFrontComponentConfig(filepath, appPath));
|
||||
frontComponentManifests.push(
|
||||
await extractManifestFromFile<FrontComponentManifest>(
|
||||
filepath,
|
||||
appPath,
|
||||
{ entryProperty: 'component', jsx: true },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const relPath = toPosixRelative(filepath, appPath);
|
||||
throw new Error(
|
||||
@@ -182,16 +180,12 @@ const loadFrontComponents = async (
|
||||
}
|
||||
}
|
||||
|
||||
return components;
|
||||
return frontComponentManifests;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a nested object structure from all TypeScript source files.
|
||||
*/
|
||||
const loadSources = async (appPath: string): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
|
||||
// Get all TypeScript files in src/ folder
|
||||
const tsFiles = await loadFiles(
|
||||
['src/**/*.ts', 'generated/**/*.ts'],
|
||||
appPath,
|
||||
@@ -202,7 +196,6 @@ const loadSources = async (appPath: string): Promise<Sources> => {
|
||||
const parts = relPath.split(sep);
|
||||
const content = await fs.readFile(filepath, 'utf8');
|
||||
|
||||
// Build nested structure
|
||||
let current: Sources = sources;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
@@ -218,19 +211,12 @@ const loadSources = async (appPath: string): Promise<Sources> => {
|
||||
return sources;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the app imports from the generated folder.
|
||||
* Detects ESM imports: `import ... from '...generated'` or `import ... from '...generated/...'`
|
||||
* Detects CommonJS requires: `require('...generated')` or `require('...generated/...')`
|
||||
*/
|
||||
const checkShouldGenerate = async (appPath: string): Promise<boolean> => {
|
||||
const tsFiles = await loadFiles(['src/**/*.ts'], appPath);
|
||||
|
||||
// Matches ESM: import ... from 'generated' or from '.../generated' or from '.../generated/...'
|
||||
const esmImportPattern =
|
||||
/from\s+['"][^'"]*\/generated(?:\/[^'"]*)?['"]|from\s+['"]generated['"]/;
|
||||
|
||||
// Matches CommonJS: require('generated') or require('.../generated') or require('.../generated/...')
|
||||
const commonJsRequirePattern =
|
||||
/require\s*\(\s*['"][^'"]*\/generated(?:\/[^'"]*)?['"]\s*\)|require\s*\(\s*['"]generated['"]\s*\)/;
|
||||
|
||||
@@ -253,16 +239,11 @@ export type BuildManifestResult = {
|
||||
warnings: ValidationWarning[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an application manifest using the folder structure with jiti runtime evaluation.
|
||||
*/
|
||||
export const buildManifest = async (
|
||||
appPath: string,
|
||||
): Promise<BuildManifestResult> => {
|
||||
// Validate folder structure
|
||||
await validateFolderStructure(appPath);
|
||||
|
||||
// Load package.json and yarn.lock
|
||||
const packageJson = await parseJsoncFile(
|
||||
await findPathFile(appPath, 'package.json'),
|
||||
);
|
||||
@@ -271,54 +252,54 @@ export const buildManifest = async (
|
||||
await findPathFile(appPath, 'yarn.lock'),
|
||||
);
|
||||
|
||||
// Load application config
|
||||
const applicationConfigPath = path.join(
|
||||
appPath,
|
||||
'src',
|
||||
'app',
|
||||
'application.config.ts',
|
||||
);
|
||||
const application = await loadConfig<Application>(applicationConfigPath, appPath);
|
||||
const application = await extractManifestFromFile<Application>(
|
||||
applicationConfigPath,
|
||||
appPath,
|
||||
);
|
||||
|
||||
// Load all entities in parallel
|
||||
const [
|
||||
objects,
|
||||
objectExtensions,
|
||||
serverlessFunctions,
|
||||
frontComponents,
|
||||
roles,
|
||||
objectManifests,
|
||||
objectExtensionManifests,
|
||||
functionManifests,
|
||||
frontComponentManifests,
|
||||
roleManifests,
|
||||
sources,
|
||||
shouldGenerate,
|
||||
] = await Promise.all([
|
||||
loadObjects(appPath),
|
||||
loadObjectExtensions(appPath),
|
||||
loadFunctions(appPath),
|
||||
loadFrontComponents(appPath),
|
||||
loadRoles(appPath),
|
||||
loadObjectManifests(appPath),
|
||||
loadObjectExtensionManifests(appPath),
|
||||
loadFunctionManifests(appPath),
|
||||
loadFrontComponentManifests(appPath),
|
||||
loadRoleManifests(appPath),
|
||||
loadSources(appPath),
|
||||
checkShouldGenerate(appPath),
|
||||
]);
|
||||
|
||||
// Build manifest
|
||||
const manifest: ApplicationManifest = {
|
||||
application,
|
||||
objects,
|
||||
objects: objectManifests,
|
||||
objectExtensions:
|
||||
objectExtensions.length > 0 ? objectExtensions : undefined,
|
||||
serverlessFunctions,
|
||||
frontComponents: frontComponents.length > 0 ? frontComponents : undefined,
|
||||
roles,
|
||||
objectExtensionManifests.length > 0 ? objectExtensionManifests : undefined,
|
||||
serverlessFunctions: functionManifests,
|
||||
frontComponents:
|
||||
frontComponentManifests.length > 0 ? frontComponentManifests : undefined,
|
||||
roles: roleManifests,
|
||||
sources,
|
||||
};
|
||||
|
||||
// Validate manifest
|
||||
const validation = validateManifest({
|
||||
application,
|
||||
objects,
|
||||
objectExtensions,
|
||||
serverlessFunctions,
|
||||
frontComponents,
|
||||
roles,
|
||||
objects: objectManifests,
|
||||
objectExtensions: objectExtensionManifests,
|
||||
serverlessFunctions: functionManifests,
|
||||
frontComponents: frontComponentManifests,
|
||||
roles: roleManifests,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { createJiti } from 'jiti';
|
||||
import { type JitiOptions } from 'jiti/lib/types';
|
||||
import path from 'path';
|
||||
import { parseJsoncFile } from '../../file/utils/file-jsonc';
|
||||
|
||||
export type ExtractManifestOptions = {
|
||||
jsx?: boolean;
|
||||
entryProperty?: string;
|
||||
};
|
||||
|
||||
const getTsconfigAliases = async (
|
||||
appPath: string,
|
||||
): Promise<Record<string, string>> => {
|
||||
const tsconfigPath = path.join(appPath, 'tsconfig.json');
|
||||
|
||||
if (!(await fs.pathExists(tsconfigPath))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const tsconfig = await parseJsoncFile(tsconfigPath);
|
||||
const paths = tsconfig?.compilerOptions?.paths as
|
||||
| Record<string, string[]>
|
||||
| undefined;
|
||||
const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.';
|
||||
|
||||
if (!paths) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const aliases: Record<string, string> = {};
|
||||
|
||||
for (const [pattern, targets] of Object.entries(paths)) {
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const aliasKey = pattern.replace(/\/\*$/, '');
|
||||
const targetPath = targets[0].replace(/\/\*$/, '');
|
||||
const resolvedTarget = path.resolve(appPath, baseUrl, targetPath);
|
||||
aliases[aliasKey] = resolvedTarget;
|
||||
}
|
||||
|
||||
return aliases;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const createModuleLoader = async (
|
||||
appPath: string,
|
||||
options: { jsx?: boolean } = {},
|
||||
) => {
|
||||
const jitiOptions: JitiOptions = {
|
||||
moduleCache: false,
|
||||
fsCache: false,
|
||||
interopDefault: true,
|
||||
};
|
||||
|
||||
if (options.jsx) {
|
||||
jitiOptions.jsx = { runtime: 'automatic' };
|
||||
}
|
||||
|
||||
const aliases = await getTsconfigAliases(appPath);
|
||||
|
||||
if (Object.keys(aliases).length > 0) {
|
||||
jitiOptions.alias = aliases;
|
||||
}
|
||||
|
||||
return createJiti(appPath, jitiOptions);
|
||||
};
|
||||
|
||||
const findConfigInModule = <T>(
|
||||
module: Record<string, unknown>,
|
||||
validator?: (value: unknown) => boolean,
|
||||
): T | undefined => {
|
||||
if (module.default !== undefined) {
|
||||
if (!validator || validator(module.default)) {
|
||||
return module.default as T;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(module)) {
|
||||
if (key === 'default') continue;
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value !== 'object') continue;
|
||||
if (Array.isArray(value)) continue;
|
||||
|
||||
if (!validator || validator(value)) {
|
||||
return value as T;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
const extractImportPath = (
|
||||
source: string,
|
||||
identifier: string,
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
): string | null => {
|
||||
const escapedIdentifier = escapeRegExp(identifier);
|
||||
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
),
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
|
||||
),
|
||||
new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`),
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = source.match(pattern);
|
||||
|
||||
if (match) {
|
||||
const importPath = match[1];
|
||||
const fileDir = path.dirname(filepath);
|
||||
const absolutePath = path.resolve(fileDir, importPath);
|
||||
const relativePath = path.relative(appPath, absolutePath);
|
||||
|
||||
const resultPath = relativePath.endsWith('.ts')
|
||||
? relativePath
|
||||
: `${relativePath}.ts`;
|
||||
|
||||
return resultPath.replace(/\\/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const extractManifestFromFile = async <TManifest>(
|
||||
filepath: string,
|
||||
appPath: string,
|
||||
options: ExtractManifestOptions = {},
|
||||
): Promise<TManifest> => {
|
||||
const { jsx, entryProperty } = options;
|
||||
const jiti = await createModuleLoader(appPath, { jsx });
|
||||
|
||||
const module = (await jiti.import(filepath)) as Record<string, unknown>;
|
||||
|
||||
const configValidator = entryProperty
|
||||
? (value: unknown): boolean =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
entryProperty in value &&
|
||||
typeof (value as Record<string, unknown>)[entryProperty] === 'function'
|
||||
: undefined;
|
||||
|
||||
const config = findConfigInModule<Record<string, unknown>>(
|
||||
module,
|
||||
configValidator,
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
const expectedExport = entryProperty
|
||||
? `a config object with a "${entryProperty}" property`
|
||||
: 'a config object (default export or any named object export)';
|
||||
throw new Error(`Config file ${filepath} must export ${expectedExport}`);
|
||||
}
|
||||
|
||||
if (!entryProperty) {
|
||||
return config as TManifest;
|
||||
}
|
||||
|
||||
const entryFunction = config[entryProperty] as Function;
|
||||
const entryName = entryFunction.name;
|
||||
|
||||
if (!entryName) {
|
||||
throw new Error(
|
||||
`${entryProperty} function in ${filepath} must be a named function`,
|
||||
);
|
||||
}
|
||||
|
||||
const source = await fs.readFile(filepath, 'utf8');
|
||||
const importPath = extractImportPath(source, entryName, filepath, appPath);
|
||||
const entryPath =
|
||||
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
|
||||
|
||||
const { [entryProperty]: _, ...configWithoutEntry } = config;
|
||||
|
||||
const manifest = {
|
||||
...configWithoutEntry,
|
||||
[`${entryProperty}Name`]: entryName,
|
||||
[`${entryProperty}Path`]: entryPath,
|
||||
};
|
||||
|
||||
return manifest as TManifest;
|
||||
};
|
||||
Reference in New Issue
Block a user