[FEAT] Generate barrel export named modules and types (#11110)

# Introduction
In this PR using the Ts AST dynamically compute what to export,
gathering non-runtime types and interface in an `export type`

[Export type TypeScript
documentation](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html)

From
```ts
// index.ts
export * from "submodule"
```
To
```ts
export type { SomeType } from "submodule";
export { SomeFunction, SomeConst } from "submodule";
```


close https://github.com/twentyhq/core-team-issues/issues/644

## Motivations
- Most explicit and maintainable
- Best for tree-shaking
- Clear dependency tracking
- Prevents name collisions

## Important note
Please keep in mind that I will create, very soon, a dedicated
`generate-barrel` package in our yarn workspaces in order to:
- Make it reusable for twenty-ui
- Split in several files
- Setup lint + tsconfig
- Add tests

## Conclusion
As usual any suggestions are more than welcomed !
This commit is contained in:
Paul Rastoin
2025-03-24 15:06:16 +01:00
committed by GitHub
parent e6dec51ca6
commit 8b2a90dea1
7 changed files with 282 additions and 123 deletions

View File

@ -1,26 +1,13 @@
import prettier from '@prettier/sync';
import * as fs from 'fs';
import glob from 'glob';
import path from 'path';
import { Options } from 'prettier';
import slash from 'slash';
import ts from 'typescript';
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
const INCLUDED_EXTENSIONS = ['.ts', '.tsx'];
const EXCLUDED_EXTENSIONS = [
'.test.ts',
'.test.tsx',
'.spec.ts',
'.spec.tsx',
'.stories.ts',
'.stories.tsx',
];
const EXCLUDED_DIRECTORIES = [
'__tests__',
'__mocks__',
'__stories__',
'internal',
];
const INDEX_FILENAME = 'index';
const PACKAGE_JSON_FILENAME = 'package.json';
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
@ -78,91 +65,77 @@ const getLastPathFolder = (path: string) => path.split('/').pop();
const getSubDirectoryPaths = (directoryPath: string): string[] =>
fs
.readdirSync(directoryPath)
.filter((fileOrDirectoryName) => {
const isDirectory = fs
.statSync(path.join(directoryPath, fileOrDirectoryName))
.isDirectory();
if (!isDirectory) {
return false;
}
const isExcludedDirectory =
EXCLUDED_DIRECTORIES.includes(fileOrDirectoryName);
return !isExcludedDirectory;
})
.filter((fileOrDirectoryName) =>
fs.statSync(path.join(directoryPath, fileOrDirectoryName)).isDirectory(),
)
.map((subDirectoryName) => path.join(directoryPath, subDirectoryName));
const getDirectoryPathsRecursive = (directoryPath: string): string[] => [
directoryPath,
...getSubDirectoryPaths(directoryPath).flatMap(getDirectoryPathsRecursive),
];
const partitionFileExportsByType = (declarations: DeclarationOccurence[]) => {
return declarations.reduce<{
typeAndInterfaceDeclarations: DeclarationOccurence[];
otherDeclarations: DeclarationOccurence[];
}>(
(acc, { kind, name }) => {
if (kind === 'type' || kind === 'interface') {
return {
...acc,
typeAndInterfaceDeclarations: [
...acc.typeAndInterfaceDeclarations,
{ kind, name },
],
};
}
const getFilesPaths = (directoryPath: string): string[] =>
fs.readdirSync(directoryPath).filter((filePath) => {
const isFile = fs.statSync(path.join(directoryPath, filePath)).isFile();
if (!isFile) {
return false;
}
const isIndexFile = filePath.startsWith(INDEX_FILENAME);
if (isIndexFile) {
return false;
}
const isWhiteListedExtension = INCLUDED_EXTENSIONS.some((extension) =>
filePath.endsWith(extension),
);
const isExcludedExtension = EXCLUDED_EXTENSIONS.every(
(excludedExtension) => !filePath.endsWith(excludedExtension),
);
return isWhiteListedExtension && isExcludedExtension;
});
type ComputeExportLineForGivenFileArgs = {
filePath: string;
moduleDirectory: string; // Rename
directoryPath: string; // Rename
};
const computeExportLineForGivenFile = ({
filePath,
moduleDirectory,
directoryPath,
}: ComputeExportLineForGivenFileArgs) => {
const fileNameWithoutExtension = filePath.split('.').slice(0, -1).join('.');
const pathToImport = slash(
path.relative(
moduleDirectory,
path.join(directoryPath, fileNameWithoutExtension),
),
return {
...acc,
otherDeclarations: [...acc.otherDeclarations, { kind, name }],
};
},
{
typeAndInterfaceDeclarations: [],
otherDeclarations: [],
},
);
// TODO refactor should extract all exports atomically please refer to https://github.com/twentyhq/core-team-issues/issues/644
return `export * from './${pathToImport}';`;
};
const generateModuleIndexFiles = (moduleDirectories: string[]) => {
return moduleDirectories.map<createTypeScriptFileArgs>((moduleDirectory) => {
const directoryPaths = getDirectoryPathsRecursive(moduleDirectory);
const content = directoryPaths
.flatMap((directoryPath) => {
const directFilesPaths = getFilesPaths(directoryPath);
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
return exportByBarrel.map<createTypeScriptFileArgs>(
({ barrel: { moduleDirectory }, allFileExports }) => {
const content = allFileExports
.map(({ exports, file }) => {
const { otherDeclarations, typeAndInterfaceDeclarations } =
partitionFileExportsByType(exports);
return directFilesPaths.map((filePath) =>
computeExportLineForGivenFile({
directoryPath,
filePath,
moduleDirectory: moduleDirectory,
}),
);
})
.sort((a, b) => a.localeCompare(b)) // Could be removed as using prettier afterwards anw ?
.join('\n');
const fileWithoutExtension = file.split('.').slice(0, -1).join('.');
const pathToImport = slash(
path.relative(moduleDirectory, fileWithoutExtension),
);
const mapDeclarationNameAndJoin = (
declarations: DeclarationOccurence[],
) => declarations.map(({ name }) => name).join(', ');
return {
content,
path: moduleDirectory,
filename: INDEX_FILENAME,
};
});
const typeExport =
typeAndInterfaceDeclarations.length > 0
? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"`
: '';
const othersExport =
otherDeclarations.length > 0
? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"`
: '';
return [typeExport, othersExport]
.filter((el) => el !== '')
.join('\n');
})
.join('\n');
return {
content,
path: moduleDirectory,
filename: INDEX_FILENAME,
};
},
);
};
type JsonUpdate = Record<string, any>;
@ -201,7 +174,7 @@ const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => {
...initialJsonFile.targets,
build: {
...initialJsonFile.targets.build,
outputs,
outputs,
},
},
},
@ -236,17 +209,200 @@ const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => {
return ['{projectRoot}/dist', ...dynamicOutputsPath];
};
const EXCLUDED_EXTENSIONS = [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.stories.ts',
'**/*.stories.tsx',
];
const EXCLUDED_DIRECTORIES = [
'**/__tests__/**',
'**/__mocks__/**',
'**/__stories__/**',
'**/internal/**',
];
function getTypeScriptFiles(
directoryPath: string,
includeIndex: boolean = false,
): string[] {
const pattern = path.join(directoryPath, '**/*.{ts,tsx}');
const files = glob.sync(pattern, {
ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES],
});
return files.filter(
(file) =>
!file.endsWith('.d.ts') &&
(includeIndex ? true : !file.endsWith('index.ts')),
);
}
const getKind = (
node: ts.VariableStatement,
): Extract<ExportKind, 'const' | 'let' | 'var'> => {
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
if (isConst) {
return 'const';
}
const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0;
if (isLet) {
return 'let';
}
return 'var';
};
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
const exports: DeclarationOccurence[] = [];
function visit(node: ts.Node) {
if (!ts.canHaveModifiers(node)) {
return ts.forEachChild(node, visit);
}
const modifiers = ts.getModifiers(node);
const isExport = modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExport) {
return ts.forEachChild(node, visit);
}
switch (true) {
case ts.isTypeAliasDeclaration(node):
exports.push({
kind: 'type',
name: node.name.text,
});
break;
case ts.isInterfaceDeclaration(node):
exports.push({
kind: 'interface',
name: node.name.text,
});
break;
case ts.isEnumDeclaration(node):
exports.push({
kind: 'enum',
name: node.name.text,
});
break;
case ts.isFunctionDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'function',
name: node.name.text,
});
break;
case ts.isVariableStatement(node):
node.declarationList.declarations.forEach((decl) => {
if (ts.isIdentifier(decl.name)) {
const kind = getKind(node);
exports.push({
kind,
name: decl.name.text,
});
}
});
break;
case ts.isClassDeclaration(node) && node.name !== undefined:
exports.push({
kind: 'class',
name: node.name.text,
});
break;
}
return ts.forEachChild(node, visit);
}
visit(sourceFile);
return exports;
}
type ExportKind =
| 'type'
| 'interface'
| 'enum'
| 'function'
| 'const'
| 'let'
| 'var'
| 'class';
type DeclarationOccurence = { kind: ExportKind; name: string };
type FileExports = Array<{
file: string;
exports: DeclarationOccurence[];
}>;
function findAllExports(directoryPath: string): FileExports {
const results: FileExports = [];
const files = getTypeScriptFiles(directoryPath);
for (const file of files) {
const sourceFile = ts.createSourceFile(
file,
fs.readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const exports = extractExportsFromSourceFile(sourceFile);
if (exports.length > 0) {
results.push({
file,
exports,
});
}
}
return results;
}
type ExportByBarrel = {
barrel: {
moduleName: string;
moduleDirectory: string;
};
allFileExports: FileExports;
};
const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
return barrelDirectories.map<ExportByBarrel>((moduleDirectory) => {
const moduleExportsPerFile = findAllExports(moduleDirectory);
const moduleName = moduleDirectory.split('/').pop();
if (!moduleName) {
throw new Error(
`Should never occur moduleName not found ${moduleDirectory}`,
);
}
return {
barrel: {
moduleName,
moduleDirectory,
},
allFileExports: moduleExportsPerFile,
};
});
};
const main = () => {
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
const moduleIndexFiles = generateModuleIndexFiles(moduleDirectories);
const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories);
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
const packageJsonPreconstructConfigAndFiles =
computePackageJsonFilesAndPreconstructConfig(moduleDirectories);
const nxBuildOutputsPath =
computeProjectNxBuildOutputsPath(moduleDirectories);
updateNxProjectConfigurationBuildOutputs(
nxBuildOutputsPath
);
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
writeInPackageJson(packageJsonPreconstructConfigAndFiles);
moduleIndexFiles.forEach(createTypeScriptFile);
};

View File

@ -7,8 +7,8 @@
* |___/
*/
export * from './FieldForTotalCountAggregateOperation';
export * from './PermissionsOnAllObjectRecords';
export * from './StandardObjectRecordsUnderObjectRecordsPermissions';
export * from './TwentyCompaniesBaseUrl';
export * from './TwentyIconsBaseUrl';
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions';
export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl';
export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl';

View File

@ -7,4 +7,4 @@
* |___/
*/
export * from './types/EachTestingContext.type';
export type { EachTestingContext } from './types/EachTestingContext.type';

View File

@ -7,5 +7,5 @@
* |___/
*/
export * from './constants/AppLocales';
export * from './constants/SourceLocale';
export { APP_LOCALES } from './constants/AppLocales';
export { SOURCE_LOCALE } from './constants/SourceLocale';

View File

@ -7,6 +7,6 @@
* |___/
*/
export * from './ConnectedAccountProvider';
export * from './FieldMetadataType';
export * from './IsExactly';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export { FieldMetadataType } from './FieldMetadataType';
export type { IsExactly } from './IsExactly';

View File

@ -7,16 +7,19 @@
* |___/
*/
export * from './assertUnreachable';
export * from './fieldMetadata/isFieldMetadataDateKind';
export * from './image/getImageAbsoluteURI';
export * from './image/getLogoUrlFromDomainName';
export * from './strings/capitalize';
export * from './url/absoluteUrlSchema';
export * from './url/getAbsoluteUrlOrThrow';
export * from './url/getUrlHostnameOrThrow';
export * from './url/isValidHostname';
export * from './url/isValidUrl';
export * from './validation/isDefined';
export * from './validation/isValidLocale';
export * from './validation/isValidUuid';
export { assertUnreachable } from './assertUnreachable';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { getImageAbsoluteURI } from './image/getImageAbsoluteURI';
export {
sanitizeURL,
getLogoUrlFromDomainName,
} from './image/getLogoUrlFromDomainName';
export { capitalize } from './strings/capitalize';
export { absoluteUrlSchema } from './url/absoluteUrlSchema';
export { getAbsoluteUrlOrThrow } from './url/getAbsoluteUrlOrThrow';
export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow';
export { isValidHostname } from './url/isValidHostname';
export { isValidUrl } from './url/isValidUrl';
export { isDefined } from './validation/isDefined';
export { isValidLocale } from './validation/isValidLocale';
export { isValidUuid } from './validation/isValidUuid';

View File

@ -7,5 +7,5 @@
* |___/
*/
export * from './types/WorkspaceActivationStatus';
export * from './utils/isWorkspaceActiveOrSuspended';
export { WorkspaceActivationStatus } from './types/WorkspaceActivationStatus';
export { isWorkspaceActiveOrSuspended } from './utils/isWorkspaceActiveOrSuspended';