├── .gitignore ├── src ├── commands │ ├── stat │ │ ├── queue │ │ │ └── stat-queue.ts │ │ ├── options │ │ │ ├── extensions-option.ts │ │ │ ├── modules-options.ts │ │ │ └── path-option.ts │ │ ├── tasks │ │ │ ├── direct.dependencies.task.ts │ │ │ ├── dependencies.tree.task.ts │ │ │ ├── total.transferred.size.task.ts │ │ │ └── bundle.size.task.ts │ │ └── stat-command.ts │ ├── test │ │ ├── queue │ │ │ └── test-queue.ts │ │ ├── tasks │ │ │ ├── run.e2e.tests.task.ts │ │ │ └── run.unit.tests.task.ts │ │ └── test-command.ts │ ├── build │ │ ├── queue │ │ │ └── build-queue.ts │ │ ├── options │ │ │ ├── verbose-option.ts │ │ │ ├── force-option.ts │ │ │ ├── watch-option.ts │ │ │ ├── modules-option.ts │ │ │ ├── extensions-option.ts │ │ │ └── path-option.ts │ │ ├── tasks │ │ │ ├── statistic │ │ │ │ ├── tasks │ │ │ │ │ ├── direct.dependencies.task.ts │ │ │ │ │ ├── total.transferred.size.task.ts │ │ │ │ │ ├── dependencies.tree.task.ts │ │ │ │ │ └── bundle.size.task.ts │ │ │ │ └── statistic.task.ts │ │ │ ├── lint │ │ │ │ └── lint.task.ts │ │ │ └── build │ │ │ │ └── build.task.ts │ │ └── build-command.ts │ ├── flow-to-ts │ │ ├── queue │ │ │ └── convert-queue.ts │ │ ├── options │ │ │ └── path-option.ts │ │ ├── tasks │ │ │ ├── convert-flow-syntax.task.ts │ │ │ └── rename-file.task.ts │ │ └── flow-to-ts.command.ts │ ├── create │ │ └── create.ts │ └── generate-tsconfig │ │ ├── options │ │ └── path-option.ts │ │ └── generate-tsconfig.command.ts ├── utils │ ├── cli │ │ ├── parse-arg-value.ts │ │ └── prepare-path.ts │ ├── is.external.dependency.name.ts │ ├── pluralize.ts │ ├── array.to.object.ts │ ├── format.size.ts │ ├── vcs │ │ └── hg │ │ │ └── rename.ts │ ├── package │ │ ├── resolve-package.ts │ │ ├── find-packages.ts │ │ ├── create-package-name.ts │ │ └── build.dependencies.tree.ts │ ├── flatten.tree.ts │ ├── memory-cache.ts │ ├── path │ │ ├── path-indicators.ts │ │ ├── path-detector.ts │ │ └── path-parser.ts │ ├── generate.tree.ts │ └── flow-to-ts.ts ├── modules │ ├── packages │ │ ├── types │ │ │ └── dependency.node.ts │ │ ├── package │ │ │ ├── template-package.ts │ │ │ ├── component-package.ts │ │ │ ├── extension-package.ts │ │ │ └── custom-package.ts │ │ ├── strategies │ │ │ ├── default-strategy.ts │ │ │ ├── source │ │ │ │ ├── index.ts │ │ │ │ ├── extension-strategy.ts │ │ │ │ ├── template-strategy.ts │ │ │ │ └── component-strategy.ts │ │ │ └── project │ │ │ │ ├── index.ts │ │ │ │ ├── extension-strategy.ts │ │ │ │ ├── template-strategy.ts │ │ │ │ └── component-strategy.ts │ │ ├── providers │ │ │ └── package-factory-provider.ts │ │ ├── package-factory.ts │ │ ├── package.resolver.ts │ │ └── base-package.ts │ ├── task │ │ ├── icons.ts │ │ └── task.ts │ ├── config │ │ ├── config.strategy.ts │ │ ├── php │ │ │ ├── strategies │ │ │ │ ├── oninit.strategy.ts │ │ │ │ ├── bundle.css.strategy.ts │ │ │ │ ├── bundle.js.strategy.ts │ │ │ │ ├── rel.strategy.ts │ │ │ │ ├── skip.core.strategy.ts │ │ │ │ ├── includes.strategy.ts │ │ │ │ ├── post.rel.strategy.ts │ │ │ │ ├── js.strategy.ts │ │ │ │ ├── css.strategy.ts │ │ │ │ ├── lang.strategy.ts │ │ │ │ ├── settings.strategy.ts │ │ │ │ ├── lang.additional.strategy.ts │ │ │ │ └── index.ts │ │ │ ├── parser │ │ │ │ ├── internal │ │ │ │ │ ├── parse-php-code-to-ast.ts │ │ │ │ │ └── return-node-to-js-object.ts │ │ │ │ └── php.config.parser.ts │ │ │ └── php.config.manager.ts │ │ ├── bundle │ │ │ ├── strategies │ │ │ │ ├── namespace.strategy.ts │ │ │ │ ├── protected.strategy.ts │ │ │ │ ├── source.maps.strategy.ts │ │ │ │ ├── namespace.function.strategy.ts │ │ │ │ ├── input.strategy.ts │ │ │ │ ├── treeshake.strategy.ts │ │ │ │ ├── adjust.config.php.strategy.ts │ │ │ │ ├── transform.classes.strategy.ts │ │ │ │ ├── minification.strategy.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugins.strategy.ts │ │ │ │ ├── concat.strategy.ts │ │ │ │ ├── css.images.strategy.ts │ │ │ │ ├── browserslist.strategy.ts │ │ │ │ ├── tests.strategy.ts │ │ │ │ ├── resolve.files.import.strategy.ts │ │ │ │ └── output.strategy.ts │ │ │ ├── bundle.config.manager.ts │ │ │ ├── bundle.config.ts │ │ │ └── source.bundle.config.ts │ │ └── config.manager.ts │ ├── formatters │ │ └── lint │ │ │ ├── summary.formatter.ts │ │ │ └── verbose.formatter.ts │ └── linter │ │ └── lint.result.ts ├── environment │ ├── utils │ │ ├── has-indicators.ts │ │ ├── find-root-by-indicators.ts │ │ └── get-context.ts │ └── environment.ts ├── hooks │ ├── adjust-cwd-pre-action.ts │ └── check-cwd-pre-action.ts └── cli.ts ├── tsconfig.json ├── bin └── bitrix ├── test ├── test-utils │ └── code.ts └── utils │ └── flow-to-ts │ └── flow-to-ts.test.ts ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /src/commands/stat/queue/stat-queue.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | 3 | export const statQueue = new PQueue({ 4 | concurrency: 1, 5 | }); 6 | -------------------------------------------------------------------------------- /src/commands/test/queue/test-queue.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | 3 | export const testQueue = new PQueue({ 4 | concurrency: 1, 5 | }); 6 | -------------------------------------------------------------------------------- /src/commands/build/queue/build-queue.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | 3 | export const buildQueue = new PQueue({ 4 | concurrency: 1, 5 | }); 6 | -------------------------------------------------------------------------------- /src/utils/cli/parse-arg-value.ts: -------------------------------------------------------------------------------- 1 | export function parseArgValue(value: string): Array 2 | { 3 | return value.replace(/^=/, '').split(','); 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/is.external.dependency.name.ts: -------------------------------------------------------------------------------- 1 | export function isExternalDependencyName(name: string): boolean 2 | { 3 | return !name.startsWith('./'); 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/pluralize.ts: -------------------------------------------------------------------------------- 1 | export function pluralize(word: string, count: number): string 2 | { 3 | return count + (count === 1 ? word : `${word}s`); 4 | } 5 | -------------------------------------------------------------------------------- /src/commands/flow-to-ts/queue/convert-queue.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | 3 | export const convertQueue = new PQueue({ 4 | concurrency: 1, 5 | }); 6 | -------------------------------------------------------------------------------- /src/commands/build/options/verbose-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | 3 | export const verboseOption = new Option( 4 | '-v, --verbose', 5 | 'Print verbose logs', 6 | ); 7 | -------------------------------------------------------------------------------- /src/commands/build/options/force-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | 3 | export const forceOption = new Option( 4 | '-f, --force', 5 | 'Force action, bypassing warnings and checks', 6 | ); 7 | -------------------------------------------------------------------------------- /src/modules/packages/types/dependency.node.ts: -------------------------------------------------------------------------------- 1 | export type DependencyNode = { 2 | name: string; 3 | visited?: boolean; 4 | children?: Array; 5 | bundlesSize?: { css: number, js: number }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/commands/build/options/watch-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | 3 | export const watchOption = new Option( 4 | '-w, --watch', 5 | 'Enable watch mode: automatically rebuild on file changes', 6 | ); 7 | -------------------------------------------------------------------------------- /src/modules/task/icons.ts: -------------------------------------------------------------------------------- 1 | export const TASK_STATUS_ICON = { 2 | success: '✔', 3 | warning: '⚠', 4 | fail: '✖', 5 | arrowDown: '↓', 6 | arrowRight: '→', 7 | arrowLeft: '←', 8 | pointer: '❯', 9 | pointerSmall: '›' 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/array.to.object.ts: -------------------------------------------------------------------------------- 1 | export function arrayToObject(arr: T[]): Record 2 | { 3 | return arr.reduce((acc, key: any) => { 4 | acc[key.name] = {}; 5 | return acc; 6 | }, {} as Record); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "outDir": "./dist", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/config/config.strategy.ts: -------------------------------------------------------------------------------- 1 | export interface ConfigStrategy { 2 | key: string; 3 | getDefault?(): T | undefined; 4 | prepare?(value: any): T; 5 | validate?(value: T): true | string; 6 | save?(configSourceCode: string, value: T): string; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/cli/prepare-path.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | export function preparePath(value: string): string | null 4 | { 5 | if (typeof value === 'string') 6 | { 7 | return path.resolve(value); 8 | } 9 | 10 | return process.cwd(); 11 | } 12 | -------------------------------------------------------------------------------- /src/environment/utils/has-indicators.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | 3 | export function hasIndicators(dir: string, indicators: Array): boolean 4 | { 5 | const files = fs.readdirSync(dir); 6 | 7 | return indicators.every((indicator) => { 8 | return files.includes(indicator) 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/commands/create/create.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | 3 | export const create = new Command('create'); 4 | 5 | create 6 | .description('Build bitrix js-extensions') 7 | .argument('', 'Extension name') 8 | .action((extensionName: string): void => { 9 | console.log(extensionName); 10 | }); 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/modules/packages/package/template-package.ts: -------------------------------------------------------------------------------- 1 | import { BasePackage } from '../base-package'; 2 | 3 | export class TemplatePackage extends BasePackage 4 | { 5 | getName(): string 6 | { 7 | return this.getPath(); 8 | } 9 | 10 | getModuleName(): string 11 | { 12 | return this.getPath().split('/').shift(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/default-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../package-factory'; 2 | import { CustomPackage } from '../package/custom-package.js'; 3 | 4 | export const defaultStrategy: PackageFactoryStrategy = { 5 | match() { 6 | return true; 7 | }, 8 | create({ path }) { 9 | return new CustomPackage({ path }); 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/oninit.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const oninitStrategy = { 4 | key: 'oninit', 5 | getDefault(): string 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy 18 | -------------------------------------------------------------------------------- /bin/bitrix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import { createRequire } from 'module'; 6 | 7 | const require = createRequire(import.meta.url); 8 | 9 | if (fs.existsSync(path.resolve(import.meta.dirname, '../dist/cli.js'))) 10 | { 11 | require('../dist/cli.js'); 12 | } 13 | else 14 | { 15 | require('tsx').globalPreload(); 16 | require('../src/cli.ts'); 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/bundle.css.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const bundleCssStrategy = { 4 | key: 'bundle_css', 5 | getDefault(): string 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/bundle.js.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const bundleJsStrategy = { 4 | key: 'bundle_js', 5 | getDefault(): string 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/rel.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const relStrategy = { 4 | key: 'rel', 5 | getDefault(): Array 6 | { 7 | return []; 8 | }, 9 | prepare(value: any): Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/skip.core.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const skipCoreStrategy = { 4 | key: 'skip_core', 5 | getDefault(): boolean 6 | { 7 | return false; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy 18 | -------------------------------------------------------------------------------- /src/modules/packages/package/component-package.ts: -------------------------------------------------------------------------------- 1 | import { BasePackage } from '../base-package'; 2 | import { createPackageName } from '../../../utils/package/create-package-name'; 3 | 4 | export class ComponentPackage extends BasePackage 5 | { 6 | getName(): string 7 | { 8 | return createPackageName(this.getPath()); 9 | } 10 | 11 | getModuleName(): string 12 | { 13 | return this.getPath().split('/').shift(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/packages/package/extension-package.ts: -------------------------------------------------------------------------------- 1 | import { BasePackage } from '../base-package'; 2 | import { createPackageName } from '../../../utils/package/create-package-name'; 3 | 4 | export class ExtensionPackage extends BasePackage 5 | { 6 | getName(): string 7 | { 8 | return createPackageName(this.getPath()); 9 | } 10 | 11 | getModuleName(): string 12 | { 13 | return this.getPath().split('/').shift(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/includes.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const includesStrategy = { 4 | key: 'includes', 5 | getDefault(): Array 6 | { 7 | return []; 8 | }, 9 | prepare(value: any): Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/post.rel.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const postRelStrategy = { 4 | key: 'post_rel', 5 | getDefault(): Array 6 | { 7 | return []; 8 | }, 9 | prepare(value: any): Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/source/index.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { extensionStrategy } from './extension-strategy'; 3 | import { componentStrategy } from './component-strategy'; 4 | import { templateStrategy } from './template-strategy'; 5 | 6 | export const sourceStrategies: Array = [ 7 | extensionStrategy, 8 | componentStrategy, 9 | templateStrategy, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/js.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const jsStrategy = { 4 | key: 'js', 5 | getDefault(): string | Array 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string | Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/project/index.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { extensionStrategy } from './extension-strategy'; 3 | import { componentStrategy } from './component-strategy'; 4 | import { templateStrategy } from './template-strategy'; 5 | 6 | export const projectStrategies: Array = [ 7 | extensionStrategy, 8 | componentStrategy, 9 | templateStrategy, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/modules/config/php/parser/internal/parse-php-code-to-ast.ts: -------------------------------------------------------------------------------- 1 | import { Engine, Program } from 'php-parser'; 2 | 3 | export function parsePhpCodeToAst(phpCode: string): Program 4 | { 5 | const parser = new Engine({ 6 | parser: { 7 | debug: false, 8 | locations: false, 9 | extractDoc: false, 10 | }, 11 | ast: { 12 | withPositions: false, 13 | }, 14 | }); 15 | 16 | return parser.parseEval(phpCode.replace(/<\?(php)?/, '')); 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/css.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const cssStrategy = { 4 | key: 'css', 5 | getDefault(): string | Array 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string | Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/lang.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const langStrategy = { 4 | key: 'lang', 5 | getDefault(): string | Array 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string | Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/settings.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const settingsStrategy = { 4 | key: 'settings', 5 | getDefault(): { [key: string]: any } 6 | { 7 | return {}; 8 | }, 9 | prepare(value: any): { [key: string]: any } 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy<{ [key: string]: any }> 18 | -------------------------------------------------------------------------------- /src/modules/packages/package/custom-package.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { BasePackage } from '../base-package'; 3 | import { Environment } from '../../../environment/environment'; 4 | 5 | export class CustomPackage extends BasePackage 6 | { 7 | getName(): string 8 | { 9 | return path.relative(Environment.getRoot(), this.getPath()); 10 | } 11 | 12 | getModuleName(): string 13 | { 14 | return this.getPath().split('/').shift(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/source/extension-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { ExtensionPackage } from '../../package/extension-package'; 3 | import { PathDetector } from '../../../../utils/path/path-detector'; 4 | 5 | export const extensionStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return PathDetector.isInstallJs(path); 8 | }, 9 | create: ({ path }) => new ExtensionPackage({ path }), 10 | }; 11 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/source/template-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { TemplatePackage } from '../../package/template-package'; 3 | import { PathDetector } from '../../../../utils/path/path-detector'; 4 | 5 | export const templateStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return PathDetector.isInstallTemplates(path); 8 | }, 9 | create: ({ path }) => new TemplatePackage({ path }), 10 | }; 11 | -------------------------------------------------------------------------------- /src/commands/build/options/modules-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { parseArgValue } from '../../../utils/cli/parse-arg-value'; 3 | 4 | const modulesOption = new Option( 5 | '-m, --modules [modules...]', 6 | 'Specify Bitrix module names to build extensions from', 7 | ); 8 | 9 | modulesOption.conflicts([ 10 | 'extensions', 11 | 'path', 12 | ]); 13 | 14 | modulesOption.argParser( 15 | parseArgValue, 16 | ); 17 | 18 | export { 19 | modulesOption, 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/lang.additional.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const langAdditionalStrategy = { 4 | key: 'lang_additional', 5 | getDefault(): string | Array 6 | { 7 | return ''; 8 | }, 9 | prepare(value: any): string | Array 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | } satisfies ConfigStrategy> 18 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/source/component-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { PathDetector } from '../../../../utils/path/path-detector'; 3 | import { ComponentPackage } from '../../package/component-package'; 4 | 5 | export const componentStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return PathDetector.isInstallComponents(path); 8 | }, 9 | create: ({ path }) => new ComponentPackage({ path }), 10 | }; 11 | -------------------------------------------------------------------------------- /src/commands/build/options/extensions-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { parseArgValue } from '../../../utils/cli/parse-arg-value'; 3 | 4 | const extensionsOption = new Option( 5 | '-e, --extensions [extensions,...]', 6 | 'Specify exact extension names to build', 7 | ); 8 | 9 | extensionsOption.conflicts([ 10 | 'modules', 11 | 'path', 12 | ]); 13 | 14 | extensionsOption.argParser( 15 | parseArgValue, 16 | ); 17 | 18 | export { 19 | extensionsOption, 20 | }; 21 | -------------------------------------------------------------------------------- /src/commands/stat/options/extensions-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { parseArgValue } from '../../../utils/cli/parse-arg-value'; 3 | 4 | const extensionsOption = new Option( 5 | '-e, --extensions [extensions,...]', 6 | 'Show stats only for the specified extension(s)', 7 | ); 8 | 9 | extensionsOption.conflicts([ 10 | 'modules', 11 | 'path', 12 | ]); 13 | 14 | extensionsOption.argParser( 15 | parseArgValue, 16 | ); 17 | 18 | export { 19 | extensionsOption, 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/config/php/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rel.strategy'; 2 | export * from './js.strategy'; 3 | export * from './css.strategy'; 4 | export * from './settings.strategy'; 5 | export * from './post.rel.strategy'; 6 | export * from './lang.strategy'; 7 | export * from './lang.additional.strategy'; 8 | export * from './skip.core.strategy'; 9 | export * from './oninit.strategy'; 10 | export * from './includes.strategy'; 11 | export * from './bundle.js.strategy'; 12 | export * from './bundle.css.strategy'; 13 | -------------------------------------------------------------------------------- /src/commands/build/options/path-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { preparePath } from '../../../utils/cli/prepare-path'; 3 | 4 | const pathOption = new Option( 5 | '-p, --path [path]', 6 | 'Specify a custom path to search and build extensions in it', 7 | ); 8 | 9 | pathOption.conflicts([ 10 | 'extensions', 11 | 'modules', 12 | ]); 13 | 14 | pathOption.argParser( 15 | preparePath, 16 | ); 17 | 18 | pathOption.default( 19 | process.cwd(), 20 | ); 21 | 22 | export { 23 | pathOption, 24 | }; 25 | -------------------------------------------------------------------------------- /src/commands/flow-to-ts/options/path-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { preparePath } from '../../../utils/cli/prepare-path'; 3 | 4 | const pathOption = new Option( 5 | '-p, --path [path]', 6 | 'Specify a custom path to search and build extensions in it', 7 | ); 8 | 9 | pathOption.conflicts([ 10 | 'extensions', 11 | 'modules', 12 | ]); 13 | 14 | pathOption.argParser( 15 | preparePath, 16 | ); 17 | 18 | pathOption.default( 19 | process.cwd(), 20 | ); 21 | 22 | export { 23 | pathOption, 24 | }; 25 | -------------------------------------------------------------------------------- /src/commands/stat/options/modules-options.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { parseArgValue } from '../../../utils/cli/parse-arg-value'; 3 | 4 | const modulesOptions = new Option( 5 | '-m, --modules [modules...]', 6 | 'Treat the provided arguments as module names — show all extensions related to those modules.', 7 | ); 8 | 9 | modulesOptions.conflicts([ 10 | 'extensions', 11 | 'path', 12 | ]); 13 | 14 | modulesOptions.argParser( 15 | parseArgValue, 16 | ); 17 | 18 | export { 19 | modulesOptions, 20 | }; 21 | -------------------------------------------------------------------------------- /src/commands/generate-tsconfig/options/path-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { preparePath } from '../../../utils/cli/prepare-path'; 3 | 4 | const pathOption = new Option( 5 | '-p, --path [path]', 6 | 'Specify a custom path to search and build extensions in it', 7 | ); 8 | 9 | pathOption.conflicts([ 10 | 'extensions', 11 | 'modules', 12 | ]); 13 | 14 | pathOption.argParser( 15 | preparePath, 16 | ); 17 | 18 | pathOption.default( 19 | process.cwd(), 20 | ); 21 | 22 | export { 23 | pathOption, 24 | }; 25 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/namespace.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const namespaceStrategy = { 4 | key: 'namespace', 5 | getDefault(): any 6 | { 7 | return 'window'; 8 | }, 9 | prepare(value: any): string 10 | { 11 | return String(value); 12 | }, 13 | validate(value: any): true | string 14 | { 15 | if (typeof value === 'string') 16 | { 17 | return true; 18 | } 19 | 20 | return 'Invalid \'namespace\' value.'; 21 | }, 22 | } satisfies ConfigStrategy 23 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/protected.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const protectedStrategy = { 4 | key: 'protected', 5 | getDefault(): any 6 | { 7 | return false; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | return value === true; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | if (typeof value === 'boolean') 16 | { 17 | return true; 18 | } 19 | 20 | return 'Invalid \'protected\' value'; 21 | }, 22 | } satisfies ConfigStrategy 23 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/source.maps.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const sourceMapsStrategy = { 4 | key: 'sourceMaps', 5 | getDefault(): any 6 | { 7 | return true; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | return value === true; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | if (typeof value === 'boolean') 16 | { 17 | return true; 18 | } 19 | 20 | return 'Invalid \'sourceMaps\' value'; 21 | }, 22 | } satisfies ConfigStrategy 23 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/project/extension-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { ExtensionPackage } from '../../package/extension-package'; 3 | import { PathDetector } from '../../../../utils/path/path-detector'; 4 | 5 | export const extensionStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return ( 8 | PathDetector.isLocalJs(path) 9 | || PathDetector.isLocalInstallJs(path) 10 | ); 11 | }, 12 | create: ({ path }) => new ExtensionPackage({ path }), 13 | }; 14 | -------------------------------------------------------------------------------- /src/commands/stat/options/path-option.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'commander'; 2 | import { preparePath } from '../../../utils/cli/prepare-path'; 3 | 4 | const pathOption = new Option( 5 | '-p, --path [path]', 6 | 'Use a custom directory to scan for extensions instead of the current working directory', 7 | ); 8 | 9 | pathOption.conflicts([ 10 | 'extensions', 11 | 'modules', 12 | ]); 13 | 14 | pathOption.argParser( 15 | preparePath, 16 | ); 17 | 18 | pathOption.default( 19 | process.cwd(), 20 | ); 21 | 22 | export { 23 | pathOption, 24 | }; 25 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/project/template-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { TemplatePackage } from '../../package/template-package'; 3 | import { PathDetector } from '../../../../utils/path/path-detector'; 4 | 5 | export const templateStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return ( 8 | PathDetector.isLocalTemplates(path) 9 | || PathDetector.isLocalInstallTemplates(path) 10 | ); 11 | }, 12 | create: ({ path }) => new TemplatePackage({ path }), 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/packages/strategies/project/component-strategy.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactoryStrategy } from '../../package-factory'; 2 | import { PathDetector } from '../../../../utils/path/path-detector'; 3 | import { ComponentPackage } from '../../package/component-package'; 4 | 5 | export const componentStrategy: PackageFactoryStrategy = { 6 | match: ({ path }) => { 7 | return ( 8 | PathDetector.isLocalComponents(path) 9 | || PathDetector.isLocalInstallComponents(path) 10 | ); 11 | }, 12 | create: ({ path }) => new ComponentPackage({ path }), 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/format.size.ts: -------------------------------------------------------------------------------- 1 | type FormatSizeOptions = { 2 | size: number; 3 | decimals?: number; 4 | prefix?: string; 5 | }; 6 | 7 | const k = 1024; 8 | const sizes = ['B', 'KB', 'MB', 'GB']; 9 | 10 | export function formatSize(options: FormatSizeOptions): string 11 | { 12 | const { size, decimals = 2, prefix = '' } = options; 13 | 14 | if (size === 0) 15 | { 16 | return '0 B'; 17 | } 18 | 19 | const i = Math.floor(Math.log(size) / Math.log(k)); 20 | const formatted = (size / Math.pow(k, i)).toFixed(decimals); 21 | 22 | return `${prefix}${formatted} ${sizes[i]}`; 23 | } 24 | -------------------------------------------------------------------------------- /src/environment/utils/find-root-by-indicators.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { hasIndicators } from './has-indicators'; 3 | 4 | export function findRootByIndicator(startDir: string, indicators: string[]): string { 5 | let currentDir = startDir; 6 | 7 | while (true) 8 | { 9 | if (hasIndicators(currentDir, indicators)) 10 | { 11 | return currentDir; 12 | } 13 | 14 | const parentDir = path.dirname(currentDir); 15 | if (parentDir === currentDir) 16 | { 17 | break; 18 | } 19 | 20 | currentDir = parentDir; 21 | } 22 | 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/namespace.function.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const namespaceFunctionStrategy = { 4 | key: 'namespaceFunction', 5 | getDefault(): null | Function 6 | { 7 | return null; 8 | }, 9 | prepare(value: any): null | Function 10 | { 11 | return value; 12 | }, 13 | validate(value: any): true | string 14 | { 15 | if (value === null || typeof value === 'function') 16 | { 17 | return true; 18 | } 19 | 20 | return 'Invalid \'namespaceFunction\' value.'; 21 | }, 22 | } satisfies ConfigStrategy 23 | -------------------------------------------------------------------------------- /src/hooks/adjust-cwd-pre-action.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import * as path from 'node:path'; 3 | import { Environment } from '../environment/environment'; 4 | 5 | export function adjustCwdPreAction(thisCommand: Command, actionCommand: Command) 6 | { 7 | const sourceCwd = actionCommand.getOptionValue('path'); 8 | const envType = Environment.getType(); 9 | const root = Environment.getRoot(); 10 | 11 | if (envType === 'project' && sourceCwd === root) 12 | { 13 | const newCwd = path.join(sourceCwd, 'local'); 14 | actionCommand.setOptionValueWithSource('path', newCwd, sourceCwd); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/input.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const inputStrategy = { 4 | key: 'input', 5 | getDefault(): any 6 | { 7 | return './script.es6.js'; 8 | }, 9 | prepare(value: any): string 10 | { 11 | return String(value); 12 | }, 13 | validate(value: any): true | string 14 | { 15 | return true; 16 | }, 17 | save(configContent: string, value: string): string 18 | { 19 | const regexp = /input:(?:\s+)?(['"])(.*)(['"])/g; 20 | 21 | return configContent.replace(regexp, `input: $1${value}$3`); 22 | }, 23 | } satisfies ConfigStrategy 24 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/treeshake.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const treeshakeStrategy = { 4 | key: 'treeshake', 5 | getDefault(): boolean 6 | { 7 | return true; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | if (typeof value === 'boolean') 12 | { 13 | return value; 14 | } 15 | 16 | return this.getDefault(); 17 | }, 18 | validate(value: any): true | string 19 | { 20 | if (typeof value === 'boolean') 21 | { 22 | return true; 23 | } 24 | 25 | return 'Invalid \'treeshake\' value'; 26 | }, 27 | } satisfies ConfigStrategy 28 | -------------------------------------------------------------------------------- /src/environment/environment.ts: -------------------------------------------------------------------------------- 1 | import { getContext } from './utils/get-context'; 2 | 3 | type EnvContext = { 4 | type: 'project' | 'source' | 'unknown', 5 | root: string | null, 6 | }; 7 | 8 | export class Environment 9 | { 10 | static #context: EnvContext; 11 | 12 | static getContext(): EnvContext 13 | { 14 | if (!this.#context) 15 | { 16 | this.#context = getContext(process.cwd()); 17 | } 18 | 19 | return this.#context; 20 | } 21 | 22 | static getRoot(): string | null 23 | { 24 | return this.getContext().root; 25 | } 26 | 27 | static getType(): EnvContext['type'] 28 | { 29 | return this.getContext().type; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/adjust.config.php.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const adjustConfigPhpStrategy = { 4 | key: 'adjustConfigPhp', 5 | getDefault(): boolean 6 | { 7 | return true; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | if (typeof value === 'boolean') 12 | { 13 | return value; 14 | } 15 | 16 | return this.getDefault(); 17 | }, 18 | validate(value: any): true | string 19 | { 20 | if (typeof value === 'boolean') 21 | { 22 | return true; 23 | } 24 | 25 | return 'Invalid \'browserslist\' value'; 26 | }, 27 | } satisfies ConfigStrategy 28 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/transform.classes.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | export const transformClassesStrategy = { 4 | key: 'transformClasses', 5 | getDefault(): any 6 | { 7 | return false; 8 | }, 9 | prepare(value: any): boolean 10 | { 11 | if (typeof value === 'boolean') 12 | { 13 | return value; 14 | } 15 | 16 | return this.getDefault(); 17 | }, 18 | validate(value: any): true | string 19 | { 20 | if (typeof value === 'boolean') 21 | { 22 | return true; 23 | } 24 | 25 | return 'Invalid \'transformClasses\' value.'; 26 | }, 27 | } satisfies ConfigStrategy 28 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/minification.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import type { MinifyOptions } from 'terser'; 3 | import {type} from 'node:os'; 4 | 5 | export const minificationStrategy = { 6 | key: 'minification', 7 | getDefault(): boolean | MinifyOptions 8 | { 9 | return false; 10 | }, 11 | prepare(value: any): boolean | MinifyOptions 12 | { 13 | if (value && typeof value === 'object') 14 | { 15 | return value; 16 | } 17 | 18 | return this.getDefault(); 19 | }, 20 | validate(value: any): true | string 21 | { 22 | return true; 23 | }, 24 | } satisfies ConfigStrategy 25 | -------------------------------------------------------------------------------- /src/utils/vcs/hg/rename.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | import { Environment } from '../../../environment/environment'; 3 | 4 | type RenameResult = { 5 | status: 'ok' | 'fail', 6 | stderr: string, 7 | }; 8 | 9 | export async function hgRename(oldPath: string, newPath: string): Promise 10 | { 11 | const cwd = Environment.getRoot(); 12 | 13 | const hgProcess = spawnSync( 14 | 'hg', 15 | ['rename', oldPath, newPath], 16 | { 17 | cwd, 18 | stdio: 'pipe', 19 | }, 20 | ); 21 | 22 | const stderr = hgProcess.stderr.toString('utf-8'); 23 | 24 | return { 25 | status: stderr.length === 0 ? 'ok' : 'fail', 26 | stderr, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/stat/tasks/direct.dependencies.task.ts: -------------------------------------------------------------------------------- 1 | import { generateTreeString } from '../../../utils/generate.tree'; 2 | import type { BasePackage } from '../../../modules/packages/base-package'; 3 | import type { Task } from '../../../modules/task/task'; 4 | 5 | export function directDependenciesTask(extension: BasePackage, argv: Record): Task 6 | { 7 | return { 8 | title: 'Direct dependencies', 9 | run: async (context) => { 10 | const dependencies = await extension.getDependencies(); 11 | context.succeed(`Direct dependencies (${dependencies.length})`); 12 | context.log(generateTreeString(await extension.getDependencies(), ' ')); 13 | }, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/config/php/parser/php.config.parser.ts: -------------------------------------------------------------------------------- 1 | import { type Return } from 'php-parser'; 2 | import { parsePhpCodeToAst } from './internal/parse-php-code-to-ast'; 3 | import { returnNodeToJsObject } from './internal/return-node-to-js-object'; 4 | 5 | export class PhpConfigParser 6 | { 7 | parse(phpCode: string): Record 8 | { 9 | const program = parsePhpCodeToAst(phpCode); 10 | 11 | // @ts-ignore 12 | const returnNode: Return = program.children.find((node: { kind: string; }) => { 13 | return node.kind === 'return'; 14 | }); 15 | 16 | if (returnNode) 17 | { 18 | return returnNodeToJsObject(returnNode.expr); 19 | } 20 | 21 | return {}; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/package/resolve-package.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { Environment } from '../../environment/environment'; 3 | import { PathIndicators } from '../path/path-indicators'; 4 | 5 | export function resolvePackage(extensionName: string): string | null 6 | { 7 | const envType = Environment.getType(); 8 | if (envType === 'unknown') 9 | { 10 | return null; 11 | } 12 | 13 | const root = Environment.getRoot(); 14 | const [moduleName, ...trace] = extensionName.split('.'); 15 | 16 | if (envType === 'source') 17 | { 18 | return path.join(root, moduleName, PathIndicators.getInstallJs(), ...trace); 19 | } 20 | 21 | return path.join(root, PathIndicators.getLocalJs(), ...trace); 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './input.strategy'; 2 | export * from './output.strategy'; 3 | export * from './namespace.strategy'; 4 | export * from './namespace.function.strategy'; 5 | export * from './browserslist.strategy'; 6 | export * from './adjust.config.php.strategy'; 7 | export * from './minification.strategy'; 8 | export * from './css.images.strategy'; 9 | export * from './transform.classes.strategy'; 10 | export * from './plugins.strategy'; 11 | export * from './source.maps.strategy'; 12 | export * from './protected.strategy'; 13 | export * from './tests.strategy'; 14 | export * from './concat.strategy'; 15 | export * from './treeshake.strategy'; 16 | export * from './resolve.files.import.strategy'; 17 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/plugins.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import type { SourceBundleConfig } from '../source.bundle.config'; 3 | 4 | export const pluginsStrategy = { 5 | key: 'plugins', 6 | getDefault(): {} 7 | { 8 | return {}; 9 | }, 10 | prepare(value: any): SourceBundleConfig['plugins'] 11 | { 12 | if (value && typeof value === 'object') 13 | { 14 | return value; 15 | } 16 | 17 | return this.getDefault(); 18 | }, 19 | validate(value: any): true | string 20 | { 21 | if (value && typeof value === 'object') 22 | { 23 | return true; 24 | } 25 | 26 | return 'Invalid \'plugins\' value'; 27 | }, 28 | } satisfies ConfigStrategy 29 | -------------------------------------------------------------------------------- /src/commands/test/tasks/run.e2e.tests.task.ts: -------------------------------------------------------------------------------- 1 | import type { BasePackage } from '../../../modules/packages/base-package'; 2 | import type { Task } from '../../../modules/task/task'; 3 | 4 | export function runEndToEndTestsTask(extension: BasePackage, args: Record): Task 5 | { 6 | return { 7 | title: 'E2E tests', 8 | run: async (context): Promise => { 9 | const { status } = await extension.runEndToEndTests(args); 10 | 11 | if (status === 'NO_TESTS_FOUND') 12 | { 13 | context.warn('No E2E tests found'); 14 | } 15 | else if (status === 'TESTS_FAILED') 16 | { 17 | context.fail('E2E tests failed'); 18 | } 19 | else 20 | { 21 | context.succeed('E2E tests passed'); 22 | } 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/packages/providers/package-factory-provider.ts: -------------------------------------------------------------------------------- 1 | import { PackageFactory } from '../package-factory'; 2 | import { Environment } from '../../../environment/environment'; 3 | import { sourceStrategies } from '../strategies/source'; 4 | import { projectStrategies } from '../strategies/project'; 5 | import { defaultStrategy } from '../strategies/default-strategy'; 6 | 7 | export class PackageFactoryProvider 8 | { 9 | static create(): PackageFactory 10 | { 11 | const strategies = (() => { 12 | if (Environment.getType() === 'source') 13 | { 14 | return sourceStrategies; 15 | } 16 | 17 | return projectStrategies; 18 | })(); 19 | 20 | return new PackageFactory({ 21 | strategies, 22 | defaultStrategy, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/concat.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import type { SourceBundleConfig } from '../source.bundle.config'; 3 | 4 | export const concatStrategy = { 5 | key: 'concat', 6 | getDefault(): SourceBundleConfig['concat'] 7 | { 8 | return {}; 9 | }, 10 | prepare(value: any): SourceBundleConfig['concat'] 11 | { 12 | if (value && typeof value === 'object') 13 | { 14 | return value; 15 | } 16 | 17 | return this.getDefault(); 18 | }, 19 | validate(value: any): true | string 20 | { 21 | if (value && typeof value === 'object') 22 | { 23 | return true; 24 | } 25 | 26 | return 'Invalid \'concat\' value.'; 27 | }, 28 | } satisfies ConfigStrategy 29 | -------------------------------------------------------------------------------- /src/commands/build/tasks/statistic/tasks/direct.dependencies.task.ts: -------------------------------------------------------------------------------- 1 | import { generateTreeString } from '../../../../../utils/generate.tree'; 2 | import type { BasePackage } from '../../../../../modules/packages/base-package'; 3 | import type { Task } from '../../../../../modules/task/task'; 4 | 5 | export function directDependenciesTask(extension: BasePackage, argv: Record): Task 6 | { 7 | return { 8 | title: 'Direct dependencies', 9 | run: async (context, { result, level }) => { 10 | const dependencies = await extension.getDependencies(); 11 | context.succeed(`Direct dependencies (${dependencies.length})`); 12 | context.log(generateTreeString(dependencies, ' ')); 13 | 14 | return { 15 | result, 16 | level, 17 | }; 18 | }, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/flatten.tree.ts: -------------------------------------------------------------------------------- 1 | import type { DependencyNode } from '../modules/packages/types/dependency.node'; 2 | 3 | export function flattenTree(tree: Array, unique: boolean = false): Array 4 | { 5 | const nodes: DependencyNode[] = []; 6 | 7 | function walk(currentNodes: DependencyNode[]) 8 | { 9 | for (const node of currentNodes) 10 | { 11 | nodes.push(node); 12 | 13 | if (node.children && node.children.length > 0) 14 | { 15 | walk(node.children); 16 | } 17 | } 18 | } 19 | 20 | walk(tree); 21 | 22 | if (unique) 23 | { 24 | const seen = new Set(); 25 | return nodes.filter(node => { 26 | if (seen.has(node.name)) 27 | { 28 | return false; 29 | } 30 | seen.add(node.name); 31 | return true; 32 | }); 33 | } 34 | 35 | return nodes; 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/css.images.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import { SourceBundleConfig } from '../source.bundle.config'; 3 | 4 | export const cssImagesStrategy = { 5 | key: 'cssImages', 6 | getDefault(): SourceBundleConfig['cssImages'] 7 | { 8 | return { 9 | type: 'inline', 10 | maxSize: 14, 11 | svgo: true, 12 | }; 13 | }, 14 | prepare(value: any): SourceBundleConfig['cssImages'] 15 | { 16 | if (value && typeof value === 'object') 17 | { 18 | return { ...this.getDefault(), ...value }; 19 | } 20 | }, 21 | validate(value: any): true | string 22 | { 23 | if (value && typeof value === 'object') 24 | { 25 | return true; 26 | } 27 | 28 | return 'Invalid \'cssImages\' value.'; 29 | }, 30 | } satisfies ConfigStrategy 31 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/browserslist.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | 4 | export const browserslistStrategy = { 5 | key: 'browserslist', 6 | getDefault(): Array 7 | { 8 | return [ 9 | 'IE >= 11', 10 | 'last 4 version', 11 | ]; 12 | }, 13 | prepare(value: any): string | Array 14 | { 15 | if (value === false || value === undefined) 16 | { 17 | return [ 18 | 'IE >= 11', 19 | 'last 4 version', 20 | ]; 21 | } 22 | 23 | return value; 24 | }, 25 | validate(value: any): true | string 26 | { 27 | if (typeof value === 'string' || typeof value === 'boolean' || Array.isArray(value)) 28 | { 29 | return true; 30 | } 31 | 32 | return 'Invalid \'browserslist\' value'; 33 | }, 34 | } satisfies ConfigStrategy> 35 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/tests.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import {SourceBundleConfig} from '../source.bundle.config'; 3 | 4 | export const testsStrategy = { 5 | key: 'tests', 6 | getDefault(): SourceBundleConfig['tests'] 7 | { 8 | return { 9 | localization: { 10 | languageId: 'en', 11 | autoLoad: true, 12 | }, 13 | }; 14 | }, 15 | prepare(value: any): SourceBundleConfig['tests'] 16 | { 17 | if (value && typeof value === 'object') 18 | { 19 | return { ...this.getDefault(), ...value }; 20 | } 21 | 22 | return this.getDefault(); 23 | }, 24 | validate(value: any): true | string 25 | { 26 | if (value && typeof value === 'object') 27 | { 28 | return true; 29 | } 30 | 31 | return 'Invalid \'test\' value'; 32 | }, 33 | } satisfies ConfigStrategy 34 | -------------------------------------------------------------------------------- /src/environment/utils/get-context.ts: -------------------------------------------------------------------------------- 1 | import { findRootByIndicator } from './find-root-by-indicators'; 2 | 3 | const MODULE_REPO_INDICATORS = ['main', 'ui', 'crm']; 4 | const BITRIX_ROOT_INDICATORS = ['bitrix', 'index.php', 'urlrewrite.php']; 5 | 6 | type Context = { 7 | type: 'project' | 'source' | 'unknown', 8 | root: string | null, 9 | }; 10 | 11 | export function getContext(cwd: string): Context 12 | { 13 | const bitrixRoot = findRootByIndicator(cwd, BITRIX_ROOT_INDICATORS); 14 | if (bitrixRoot) 15 | { 16 | return { 17 | type: 'project', 18 | root: bitrixRoot, 19 | }; 20 | } 21 | 22 | const moduleRepoRoot = findRootByIndicator(cwd, MODULE_REPO_INDICATORS); 23 | if (moduleRepoRoot) 24 | { 25 | return { 26 | type: 'source', 27 | root: moduleRepoRoot, 28 | }; 29 | } 30 | 31 | return { 32 | type: 'unknown', 33 | root: null, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/formatters/lint/summary.formatter.ts: -------------------------------------------------------------------------------- 1 | import { LintResult } from '../../linter/lint.result'; 2 | import { pluralize } from '../../../utils/pluralize'; 3 | 4 | export async function summaryFormatter(result: LintResult): Promise<{ title: string, text: string; level: 'succeed' | 'warn' | 'fail'; }> 5 | { 6 | if (result.hasErrors()) 7 | { 8 | return { 9 | level: 'fail', 10 | title: `ESLint: Found ${pluralize(' error', result.getErrorsCount())} and ${pluralize(' warning', result.getWarningsCount())}.`, 11 | text: '', 12 | }; 13 | } 14 | 15 | if (result.hasWarnings()) 16 | { 17 | return { 18 | level: 'warn', 19 | title: `ESLint: Found ${pluralize(' warning', result.getWarningsCount())}.`, 20 | text: '', 21 | }; 22 | } 23 | 24 | return { 25 | title: 'Eslint: All clean (0 errors, 0 warnings)', 26 | level: 'succeed', 27 | text: '', 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/memory-cache.ts: -------------------------------------------------------------------------------- 1 | export class MemoryCache 2 | { 3 | private store = new Map(); 4 | 5 | get(key: string, defaultValue?: T | (() => T)): T | undefined 6 | { 7 | if (this.store.has(key)) 8 | { 9 | return this.store.get(key) as T; 10 | } 11 | return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue; 12 | } 13 | 14 | set(key: string, value: T): void 15 | { 16 | this.store.set(key, value); 17 | } 18 | 19 | remember(key: string, callback: () => T): T 20 | { 21 | const existing = this.get(key); 22 | if (existing !== undefined) 23 | { 24 | return existing; 25 | } 26 | 27 | const value = callback(); 28 | this.set(key, value); 29 | 30 | return value; 31 | } 32 | 33 | forget(key: string): void 34 | { 35 | this.store.delete(key); 36 | } 37 | 38 | flush(): void 39 | { 40 | this.store.clear(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/flow-to-ts/tasks/convert-flow-syntax.task.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as fs from 'fs/promises'; 3 | import { BasePackage } from '../../../modules/packages/base-package'; 4 | import { Task, TaskContext } from '../../../modules/task/task'; 5 | import { convertFlowToTs } from '../../../utils/flow-to-ts'; 6 | 7 | export function convertFlowSyntaxTask(extension: BasePackage, file: string): Task 8 | { 9 | const relativeTsFilePath = path.relative(extension.getPath(), file); 10 | 11 | return { 12 | title: `Convert file: ${relativeTsFilePath} ...`, 13 | run: async (context: TaskContext): Promise => { 14 | const sourceCode = await fs.readFile(file, 'utf8'); 15 | const typeScriptCode = await convertFlowToTs(sourceCode); 16 | 17 | await fs.writeFile(file, typeScriptCode, 'utf8'); 18 | 19 | context.succeed(`File converted: ${relativeTsFilePath}`); 20 | }, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/stat/tasks/dependencies.tree.task.ts: -------------------------------------------------------------------------------- 1 | import { generateTreeString } from '../../../utils/generate.tree'; 2 | import type { BasePackage } from '../../../modules/packages/base-package'; 3 | import type { Task } from '../../../modules/task/task'; 4 | import chalk from 'chalk'; 5 | 6 | export function dependenciesTreeTask(extension: BasePackage, args: Record): Task 7 | { 8 | return { 9 | title: 'Dependencies tree', 10 | run: async (context) => { 11 | const dependenciesTree = await extension.getDependenciesTree({ size: true }); 12 | const uniqueDependencies = await extension.getFlattedDependenciesTree(); 13 | const allDependencies = await extension.getFlattedDependenciesTree(false); 14 | 15 | context.succeed(`Dependencies tree (${uniqueDependencies.length} (${chalk.grey(allDependencies.length)}))`); 16 | context.log(generateTreeString(dependenciesTree, ' ')); 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/check-cwd-pre-action.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { Environment } from '../environment/environment'; 3 | 4 | export function checkCwdPreAction(thisCommand: Command, actionCommand: Command) 5 | { 6 | const cwd = actionCommand.getOptionValue('path'); 7 | const envType = Environment.getType(); 8 | const root = Environment.getRoot(); 9 | 10 | if (envType === 'unknown') 11 | { 12 | console.log(`\n❌ Error: \nThe target directory is outside the project root: ${cwd}\n`); 13 | process.exit(1); 14 | } 15 | else if (envType === 'project' && !cwd.startsWith(root)) 16 | { 17 | console.log(`\n❌ Error: \nThe target directory is outside the project root: ${cwd}\n`); 18 | process.exit(1); 19 | } 20 | else if (envType === 'source' && !cwd.startsWith(root)) 21 | { 22 | console.log(`\n❌ Error: \nThe target directory is outside the project root: ${cwd}\n`); 23 | process.exit(1); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/packages/package-factory.ts: -------------------------------------------------------------------------------- 1 | import type { BasePackage } from './base-package'; 2 | 3 | export interface PackageFactoryStrategy { 4 | match({ path }): boolean; 5 | create({ path }): BasePackage; 6 | } 7 | 8 | export type PackageFactoryOptions = { 9 | strategies: Array, 10 | defaultStrategy: PackageFactoryStrategy, 11 | }; 12 | 13 | export class PackageFactory 14 | { 15 | readonly #strategies: Array; 16 | readonly #defaultStrategy: PackageFactoryStrategy; 17 | 18 | constructor(options: PackageFactoryOptions) 19 | { 20 | this.#strategies = options.strategies; 21 | this.#defaultStrategy = options.defaultStrategy; 22 | } 23 | 24 | create({ path }: { path: string }): BasePackage 25 | { 26 | for (const strategy of this.#strategies) 27 | { 28 | if (strategy.match({ path })) 29 | { 30 | return strategy.create({ path }); 31 | } 32 | } 33 | 34 | return this.#defaultStrategy.create({ path }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/build/tasks/statistic/tasks/total.transferred.size.task.ts: -------------------------------------------------------------------------------- 1 | import { formatSize } from '../../../../../utils/format.size'; 2 | import type { BasePackage } from '../../../../../modules/packages/base-package'; 3 | import type { Task } from '../../../../../modules/task/task'; 4 | 5 | export function totalTransferredSizeTask(extension: BasePackage, args: Record): Task 6 | { 7 | return { 8 | title: 'Total transferred size', 9 | run: async (context, { level, result }) => { 10 | const totalTransferredSize = await extension.getTotalTransferredSize(); 11 | context.succeed('Total transferred size'); 12 | 13 | const formattedJsSize = formatSize({ 14 | size: totalTransferredSize.js, 15 | }); 16 | 17 | const formattedCssSize = formatSize({ 18 | size: totalTransferredSize.css, 19 | }); 20 | 21 | context.log(` JS: ${formattedJsSize}, CSS: ${formattedCssSize}`); 22 | 23 | return { 24 | level, 25 | result, 26 | }; 27 | }, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/resolve.files.import.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | import type { SourceBundleConfig } from '../source.bundle.config'; 3 | 4 | export const resolveFilesImportStrategy = { 5 | key: 'resolveFilesImport', 6 | getDefault(): SourceBundleConfig['resolveFilesImport'] 7 | { 8 | return { 9 | output: './dist', 10 | include: ['**/*.svg', '**/*.png', '**/*.jpg', '**/*.gif'], 11 | exclude: [], 12 | }; 13 | }, 14 | prepare(value: any): SourceBundleConfig['resolveFilesImport'] 15 | { 16 | if (value && typeof value === 'object') 17 | { 18 | return { ...this.getDefault(), ...value }; 19 | } 20 | 21 | return this.getDefault(); 22 | }, 23 | validate(value: any): true | string 24 | { 25 | if (value && typeof value === 'object') 26 | { 27 | return true; 28 | } 29 | 30 | return 'Invalid \'resolveFilesImport\' value.'; 31 | }, 32 | } satisfies ConfigStrategy 33 | -------------------------------------------------------------------------------- /src/modules/linter/lint.result.ts: -------------------------------------------------------------------------------- 1 | import { ESLint } from 'eslint'; 2 | 3 | type LintOptions = { 4 | results?: Array, 5 | }; 6 | 7 | export class LintResult 8 | { 9 | #results: Array = []; 10 | 11 | constructor(options: LintOptions = {}) 12 | { 13 | if (Array.isArray(options.results)) 14 | { 15 | this.setResults(options.results); 16 | } 17 | } 18 | 19 | setResults(results: Array) 20 | { 21 | this.#results = results; 22 | } 23 | 24 | getResults(): Array 25 | { 26 | return this.#results; 27 | } 28 | 29 | hasErrors(): boolean 30 | { 31 | return this.getErrorsCount() > 0; 32 | } 33 | 34 | getErrorsCount(): number 35 | { 36 | return this.#results.reduce((sum, r) => sum + r.errorCount, 0); 37 | } 38 | 39 | hasWarnings(): boolean 40 | { 41 | return this.getWarningsCount() > 0; 42 | } 43 | 44 | getWarningsCount(): number 45 | { 46 | return this.#results.reduce((sum, r) => sum + r.warningCount, 0); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/flow-to-ts/tasks/rename-file.task.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { BasePackage } from '../../../modules/packages/base-package'; 3 | import { Task, TaskContext } from '../../../modules/task/task'; 4 | import { hgRename } from '../../../utils/vcs/hg/rename'; 5 | 6 | export function renameFileTask(extension: BasePackage, file: string): Task 7 | { 8 | const tsName = file.replace(/\.js$/, '.ts'); 9 | const relativeJsFilePath = path.relative(extension.getPath(), file); 10 | const relativeTsFilePath = path.relative(extension.getPath(), tsName); 11 | 12 | return { 13 | title: `Rename file: ${relativeJsFilePath} ...`, 14 | run: async (context: TaskContext): Promise => { 15 | const renameResult = await hgRename(file, tsName); 16 | if (renameResult.status === 'ok') 17 | { 18 | context.succeed(`File renamed: ${relativeJsFilePath} --> ${relativeTsFilePath}`); 19 | } 20 | else 21 | { 22 | context.fail(`Rename failed: ${relativeJsFilePath}`); 23 | } 24 | }, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/stat/tasks/total.transferred.size.task.ts: -------------------------------------------------------------------------------- 1 | import { formatSize } from '../../../utils/format.size'; 2 | import type { BasePackage } from '../../../modules/packages/base-package'; 3 | import type { Task } from '../../../modules/task/task'; 4 | import {TASK_STATUS_ICON} from '../../../modules/task/icons'; 5 | 6 | export function totalTransferredSizeTask(extension: BasePackage, args: Record): Task 7 | { 8 | return { 9 | title: 'Total transferred size', 10 | run: async (context) => { 11 | const totalTransferredSize = await extension.getTotalTransferredSize(); 12 | context.succeed('Total transferred size'); 13 | 14 | const formattedJsSize = formatSize({ 15 | size: totalTransferredSize.js, 16 | }); 17 | 18 | const formattedCssSize = formatSize({ 19 | size: totalTransferredSize.css, 20 | }); 21 | 22 | context.log(` ${TASK_STATUS_ICON.arrowRight} JS: ${formattedJsSize}`); 23 | context.log(` ${TASK_STATUS_ICON.arrowRight} CSS: ${formattedCssSize}`); 24 | }, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/config/bundle/bundle.config.manager.ts: -------------------------------------------------------------------------------- 1 | import { ConfigManager } from '../config.manager'; 2 | import * as bundleConfigStrategies from './strategies/index' 3 | import * as path from 'node:path'; 4 | import { ConfigStrategy } from '../config.strategy'; 5 | import { SourceBundleConfig } from './source.bundle.config'; 6 | import { BundleConfig } from './bundle.config'; 7 | import { createRequire } from 'module'; 8 | 9 | export class BundleConfigManager extends ConfigManager 10 | { 11 | constructor() 12 | { 13 | super(); 14 | 15 | Object.values(bundleConfigStrategies).forEach((strategy: ConfigStrategy) => { 16 | this.registerStrategy(strategy.key, strategy); 17 | }); 18 | } 19 | 20 | loadFromFile(configPath: string): any 21 | { 22 | const require = createRequire(import.meta.url); 23 | const sourceBundleConfig: SourceBundleConfig = require(path.resolve(configPath)); 24 | 25 | Object.entries(sourceBundleConfig).forEach(([key, value]) => { 26 | this.set(key, value); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | 3 | import { buildCommand } from './commands/build/build-command'; 4 | import { testCommand } from './commands/test/test-command'; 5 | import { create } from './commands/create/create'; 6 | import { statCommand } from './commands/stat/stat-command'; 7 | import { generateTsconfigCommand } from "./commands/generate-tsconfig/generate-tsconfig.command"; 8 | import { checkCwdPreAction } from './hooks/check-cwd-pre-action'; 9 | import { adjustCwdPreAction } from './hooks/adjust-cwd-pre-action'; 10 | import { flowToTsCommand } from './commands/flow-to-ts/flow-to-ts.command'; 11 | 12 | program 13 | .name('bitrix') 14 | .description('CLI tool for building and testing bitrix extensions.') 15 | .addCommand(buildCommand) 16 | .addCommand(statCommand) 17 | .addCommand(testCommand) 18 | .addCommand(create) 19 | .addCommand(generateTsconfigCommand) 20 | .addCommand(flowToTsCommand) 21 | .hook('preAction', adjustCwdPreAction) 22 | .hook('preAction', checkCwdPreAction) 23 | .parse(process.argv); 24 | -------------------------------------------------------------------------------- /src/modules/config/bundle/bundle.config.ts: -------------------------------------------------------------------------------- 1 | import type { TransformOptions } from '@babel/core'; 2 | import type { MinifyOptions } from 'terser'; 3 | 4 | export interface BundleConfig { 5 | input: string; 6 | output: { js: string; css: string }; 7 | namespace: string; 8 | concat: { 9 | js?: string[]; 10 | css?: string[]; 11 | }; 12 | adjustConfigPhp: boolean; 13 | treeshake: boolean; 14 | 'protected': boolean; 15 | plugins: { 16 | babel?: boolean | TransformOptions; 17 | custom?: Array any)>; 18 | }; 19 | cssImages?: { 20 | type: 'inline' | 'copy'; 21 | output: string; 22 | maxSize: number; 23 | svgo: boolean; 24 | }; 25 | resolveFilesImport: { 26 | output: string; 27 | include: string[]; 28 | exclude: string[]; 29 | }; 30 | browserslist: string | string[]; 31 | minification: boolean | MinifyOptions; 32 | transformClasses: boolean; 33 | sourceMaps: boolean; 34 | tests: { 35 | localization: { 36 | languageId: string; 37 | autoLoad: boolean; 38 | }; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/build/tasks/statistic/tasks/dependencies.tree.task.ts: -------------------------------------------------------------------------------- 1 | import { generateTreeString } from '../../../../../utils/generate.tree'; 2 | import type { BasePackage } from '../../../../../modules/packages/base-package'; 3 | import type { Task } from '../../../../../modules/task/task'; 4 | import chalk from 'chalk'; 5 | 6 | export function dependenciesTreeTask(extension: BasePackage, args: Record): Task 7 | { 8 | return { 9 | title: 'Dependencies tree', 10 | run: async (context, { result, level }) => { 11 | const dependenciesTree = await extension.getDependenciesTree({ size: true }); 12 | const uniqueDependencies = await extension.getFlattedDependenciesTree(); 13 | const allDependencies = await extension.getFlattedDependenciesTree(false); 14 | 15 | context.succeed(`Dependencies tree (${uniqueDependencies.length} (${chalk.grey(allDependencies.length)}))`); 16 | context.log(generateTreeString(dependenciesTree, ' ')); 17 | 18 | return { 19 | result, 20 | level, 21 | }; 22 | }, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/config/bundle/source.bundle.config.ts: -------------------------------------------------------------------------------- 1 | import type { TransformOptions } from '@babel/core'; 2 | import type { MinifyOptions } from 'terser'; 3 | 4 | export interface SourceBundleConfig { 5 | input: string; 6 | output: string | { js: string; css: string }; 7 | namespace?: string; 8 | concat?: { 9 | js?: string[]; 10 | css?: string[]; 11 | }; 12 | adjustConfigPhp?: boolean; 13 | treeshake?: boolean; 14 | 'protected'?: boolean; 15 | plugins?: { 16 | babel?: boolean | TransformOptions; 17 | custom?: Array any)>; 18 | }; 19 | cssImages?: { 20 | type?: 'inline' | 'copy'; 21 | output?: string; 22 | maxSize?: number; 23 | svgo?: boolean; 24 | }; 25 | resolveFilesImport?: { 26 | output?: string; 27 | include?: string[]; 28 | exclude?: string[]; 29 | }; 30 | browserslist?: boolean | string | string[]; 31 | minification?: boolean | MinifyOptions; 32 | transformClasses?: boolean; 33 | sourceMaps?: boolean; 34 | tests?: { 35 | localization?: { 36 | languageId?: string; 37 | autoLoad?: boolean; 38 | }; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/build/tasks/statistic/statistic.task.ts: -------------------------------------------------------------------------------- 1 | import { formatSize } from '../../../../utils/format.size'; 2 | import type { Task } from '../../../../modules/task/task'; 3 | import type { BasePackage } from '../../../../modules/packages/base-package'; 4 | import {directDependenciesTask} from './tasks/direct.dependencies.task'; 5 | import {dependenciesTreeTask} from './tasks/dependencies.tree.task'; 6 | import {bundleSizeTask} from './tasks/bundle.size.task'; 7 | import {totalTransferredSizeTask} from './tasks/total.transferred.size.task'; 8 | 9 | export function statisticTask(extension: BasePackage, args: Record): Task 10 | { 11 | return { 12 | title: 'Make build statistics...', 13 | run: async (context, { level, result }) => { 14 | context.succeed('Build statistics'); 15 | 16 | return { 17 | level, 18 | result, 19 | }; 20 | }, 21 | subtasks: [ 22 | directDependenciesTask(extension, args), 23 | dependenciesTreeTask(extension, args), 24 | bundleSizeTask(extension, args), 25 | totalTransferredSizeTask(extension, args), 26 | ], 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/commands/stat/tasks/bundle.size.task.ts: -------------------------------------------------------------------------------- 1 | import { formatSize } from '../../../utils/format.size'; 2 | import {TASK_STATUS_ICON} from '../../../modules/task/icons'; 3 | 4 | import type { BasePackage } from '../../../modules/packages/base-package'; 5 | import type { Task } from '../../../modules/task/task'; 6 | 7 | export function bundleSizeTask(extension: BasePackage, args: Record): Task 8 | { 9 | return { 10 | title: 'Bundle size', 11 | run: async (context) => { 12 | context.succeed('Bundle size'); 13 | const sizes = extension.getBundlesSize(); 14 | 15 | const formattedJsSize = formatSize({ 16 | size: sizes.js, 17 | }); 18 | 19 | const formattedCssSize = formatSize({ 20 | size: sizes.css, 21 | }); 22 | 23 | const formattedTotalSize = formatSize({ 24 | size: sizes.js + sizes.css, 25 | }); 26 | 27 | context.log(` ${TASK_STATUS_ICON.arrowRight} JS: ${formattedJsSize}`); 28 | context.log(` ${TASK_STATUS_ICON.arrowRight} CSS: ${formattedCssSize}`); 29 | context.log(` ${TASK_STATUS_ICON.arrowRight} Total size: ${formattedTotalSize}`); 30 | }, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/config/bundle/strategies/output.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ConfigStrategy } from '../../config.strategy'; 2 | 3 | const prepareValue = (value: string): { js: string, css: string } => { 4 | const jsBundle = value; 5 | const cssBundle = value.replace(/\.js$/, '.css'); 6 | 7 | return { 8 | js: jsBundle, 9 | css: cssBundle, 10 | }; 11 | }; 12 | 13 | export const outputStrategy = { 14 | key: 'output', 15 | getDefault(): { js: string, css: string } 16 | { 17 | return { 18 | js: 'bundle.js', 19 | css: 'bundle.css', 20 | }; 21 | }, 22 | prepare(value: any): { js: string, css: string } 23 | { 24 | if (typeof value === 'string') 25 | { 26 | return prepareValue(value) 27 | } 28 | 29 | if (value.js && !value.css) 30 | { 31 | return prepareValue(value.js); 32 | } 33 | 34 | if (value.js && value.css) 35 | { 36 | return value; 37 | } 38 | }, 39 | validate(value: any): true | string 40 | { 41 | if (typeof value === 'string' || value.js) 42 | { 43 | return true; 44 | } 45 | 46 | return 'Invalid \'output\' value'; 47 | }, 48 | } satisfies ConfigStrategy<{ js: string, css: string }> 49 | -------------------------------------------------------------------------------- /src/commands/build/tasks/statistic/tasks/bundle.size.task.ts: -------------------------------------------------------------------------------- 1 | import { formatSize } from '../../../../../utils/format.size'; 2 | import {TASK_STATUS_ICON} from '../../../../../modules/task/icons'; 3 | 4 | import type { BasePackage } from '../../../../../modules/packages/base-package'; 5 | import type { Task } from '../../../../../modules/task/task'; 6 | 7 | export function bundleSizeTask(extension: BasePackage, args: Record): Task 8 | { 9 | return { 10 | title: 'Bundle size', 11 | run: async (context, { level, result }) => { 12 | context.succeed('Bundle size'); 13 | 14 | let totalSize = 0; 15 | result.bundles.forEach((bundle) => { 16 | totalSize += bundle.size; 17 | const formattedSize = formatSize({ 18 | size: bundle.size, 19 | }); 20 | 21 | context.log(` ${TASK_STATUS_ICON.arrowRight} ${bundle.fileName}: ${formattedSize}`); 22 | }); 23 | 24 | const formattedTotalSize = formatSize({ 25 | size: totalSize, 26 | }); 27 | 28 | context.log(` ${TASK_STATUS_ICON.arrowRight} Total size: ${formattedTotalSize}`); 29 | 30 | return { 31 | level, 32 | result, 33 | }; 34 | }, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/build/tasks/lint/lint.task.ts: -------------------------------------------------------------------------------- 1 | import type { BasePackage } from '../../../../modules/packages/base-package'; 2 | import type { Task } from '../../../../modules/task/task'; 3 | import { verboseFormatter } from '../../../../modules/formatters/lint/verbose.formatter'; 4 | import { summaryFormatter } from '../../../../modules/formatters/lint/summary.formatter'; 5 | 6 | export function lintTask(extension: BasePackage, args: Record): Task 7 | { 8 | return { 9 | title: 'ESLint analysis...', 10 | run: async (context) => { 11 | const result = await extension.lint(); 12 | const { text, title, level } = await (async () => { 13 | if (args.verbose) 14 | { 15 | const verboseResult = await verboseFormatter(result); 16 | const summaryResult = await summaryFormatter(result); 17 | 18 | return { 19 | level: verboseResult.level, 20 | text: verboseResult.text, 21 | title: summaryResult.title, 22 | }; 23 | } 24 | 25 | return await summaryFormatter(result); 26 | })(); 27 | 28 | context[level](title); 29 | 30 | if (text.length > 0) 31 | { 32 | context.log(text); 33 | } 34 | 35 | return { 36 | level, 37 | }; 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/build/tasks/build/build.task.ts: -------------------------------------------------------------------------------- 1 | import type { Task } from '../../../../modules/task/task'; 2 | import type { BasePackage } from '../../../../modules/packages/base-package'; 3 | import chalk from 'chalk'; 4 | 5 | export function buildTask(extension: BasePackage, args: Record): Task 6 | { 7 | return { 8 | title: 'Building code...', 9 | run: async (context, { level }) => { 10 | if (level === 'fail' && !args.force) 11 | { 12 | context.fail('Build failed'); 13 | return { 14 | level, 15 | }; 16 | } 17 | 18 | const result = await extension.build(); 19 | if (level === 'succeed') 20 | { 21 | context.succeed('Build success'); 22 | } 23 | else 24 | { 25 | context.warn('Build with issues'); 26 | 27 | if (result.errors.length > 0) 28 | { 29 | result.errors.forEach((error) => { 30 | context.log(` ${chalk.red('✖')} ${error.message}`); 31 | }); 32 | } 33 | 34 | if (result.warnings.length > 0) 35 | { 36 | result.warnings.forEach((error) => { 37 | context.log(` ${chalk.yellow('⚠')} ${error.message}`); 38 | }); 39 | } 40 | } 41 | 42 | return { 43 | level, 44 | result, 45 | }; 46 | }, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /test/test-utils/code.ts: -------------------------------------------------------------------------------- 1 | export function code(strings: TemplateStringsArray, ...values: any[]): string 2 | { 3 | let rawCode = ''; 4 | for (let i = 0; i < strings.length; i++) 5 | { 6 | rawCode += strings[i]; 7 | if (i < values.length) 8 | { 9 | rawCode += values[i]; 10 | } 11 | } 12 | 13 | const lines = rawCode.split('\n'); 14 | 15 | let minIndent = Infinity; 16 | for (let i = 0; i < lines.length; i++) 17 | { 18 | const line = lines[i]; 19 | const trimmedLine = line.trim(); 20 | 21 | if (trimmedLine === '') 22 | { 23 | continue; 24 | } 25 | 26 | const leadingWhitespaceMatch = line.match(/^\t*/); 27 | const leadingWhitespaceLength = leadingWhitespaceMatch ? leadingWhitespaceMatch[0].length : 0; 28 | 29 | if (leadingWhitespaceLength < minIndent) 30 | { 31 | minIndent = leadingWhitespaceLength; 32 | } 33 | } 34 | 35 | if (minIndent === Infinity) 36 | { 37 | minIndent = 0; 38 | } 39 | 40 | const indentedLines = lines.map((line) => { 41 | if (line.startsWith('\t'.repeat(minIndent))) 42 | { 43 | return line.substring(minIndent); 44 | } 45 | 46 | return line; 47 | }); 48 | 49 | let processedCode = indentedLines.join('\n'); 50 | 51 | processedCode = processedCode.trim(); 52 | 53 | return processedCode; 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/package/find-packages.ts: -------------------------------------------------------------------------------- 1 | import fg from 'fast-glob'; 2 | import * as path from 'node:path'; 3 | import { Readable, Transform } from 'node:stream'; 4 | import { PackageFactory } from '../../modules/packages/package-factory'; 5 | 6 | type FindPackageOptions = { 7 | startDirectory: string, 8 | packageFactory: PackageFactory, 9 | }; 10 | 11 | export function findPackages({ startDirectory, packageFactory }: FindPackageOptions): NodeJS.ReadableStream 12 | { 13 | const patterns = [ 14 | '**/bundle.config.js', 15 | '**/script.es6.js', 16 | ]; 17 | 18 | const fastGlobStream = fg.stream( 19 | patterns, 20 | { 21 | cwd: startDirectory, 22 | dot: true, 23 | onlyFiles: true, 24 | unique: true, 25 | absolute: true, 26 | }, 27 | ); 28 | 29 | let count = 0; 30 | 31 | const transformStream = new Transform({ 32 | objectMode: true, 33 | transform(chunk: Buffer, encoding: BufferEncoding, callback: () => void) { 34 | count++; 35 | 36 | const extensionDir = path.dirname( 37 | chunk.toString(encoding), 38 | ); 39 | 40 | const extension = packageFactory.create({ 41 | path: extensionDir, 42 | }); 43 | 44 | this.push({ 45 | extension, 46 | count, 47 | }); 48 | 49 | callback(); 50 | }, 51 | flush(callback: () => void) { 52 | this.emit('done', { count }); 53 | callback(); 54 | }, 55 | }); 56 | 57 | return Readable.from(fastGlobStream).pipe(transformStream); 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/path/path-indicators.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | export class PathIndicators 4 | { 5 | static getInstallJs(): string 6 | { 7 | return path.join(path.sep, 'install', 'js', path.sep); 8 | } 9 | 10 | static getInstallComponents(): string 11 | { 12 | return path.join(path.sep, 'install', 'components', path.sep); 13 | } 14 | 15 | static getInstallTemplates(): string 16 | { 17 | return path.join(path.sep, 'install', 'templates', path.sep); 18 | } 19 | 20 | static getInstallTemplateComponents(): string 21 | { 22 | return path.join(path.sep, 'components', path.sep); 23 | } 24 | 25 | static getInstallActivities(): string 26 | { 27 | return path.join(path.sep, 'install', 'activities', path.sep); 28 | } 29 | 30 | static getModuleDev(): string 31 | { 32 | return path.join(path.sep, 'dev', path.sep); 33 | } 34 | 35 | static getLocalJs(): string 36 | { 37 | return path.join(path.sep, 'local', 'js', path.sep); 38 | } 39 | 40 | static getLocalComponents(): string 41 | { 42 | return path.join(path.sep, 'local', 'components', path.sep); 43 | } 44 | 45 | static getLocalTemplates(): string 46 | { 47 | return path.join(path.sep, 'local', 'templates', path.sep); 48 | } 49 | 50 | static getLocalActivities(): string 51 | { 52 | return path.join(path.sep, 'local', 'activities', path.sep); 53 | } 54 | 55 | static getLocalModules(): string 56 | { 57 | return path.join(path.sep, 'local', 'modules', path.sep); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/modules/formatters/lint/verbose.formatter.ts: -------------------------------------------------------------------------------- 1 | import { LintResult } from '../../linter/lint.result'; 2 | import chalk from 'chalk'; 3 | import table from 'text-table'; 4 | import { ESLint } from 'eslint'; 5 | 6 | function truncateWords(str: string, maxLength: number): string 7 | { 8 | if (str.length <= maxLength) 9 | { 10 | return str; 11 | } 12 | 13 | const truncated = str.substring(0, maxLength); 14 | 15 | return truncated.replace(/\s+\S*$/, '') + '...'; 16 | } 17 | 18 | function getLevelIcon(severity: number): string 19 | { 20 | if (severity === 2) 21 | { 22 | return chalk.red('✖'); 23 | } 24 | if (severity === 1) 25 | { 26 | return chalk.yellow('⚠'); 27 | } 28 | 29 | return chalk.white('•'); 30 | } 31 | 32 | export async function verboseFormatter(result: LintResult): Promise<{ text: string; level: 'succeed' | 'warn' | 'fail'; }> 33 | { 34 | const level = (() => { 35 | if (result.hasErrors()) 36 | { 37 | return 'fail'; 38 | } 39 | 40 | if (result.hasWarnings()) 41 | { 42 | return 'warn'; 43 | } 44 | 45 | return 'succeed'; 46 | })(); 47 | 48 | const text = result.getResults() 49 | .filter((resultItem: ESLint.LintResult) => { 50 | return resultItem.messages.length > 0 51 | }) 52 | .map((resultItem: ESLint.LintResult) => { 53 | const head = ` ${chalk.bold(resultItem.filePath.split('/src/')[1])}`; 54 | const messages = table( 55 | resultItem.messages.map((message) => { 56 | return [ 57 | ` ${getLevelIcon(message.severity)}`, 58 | `${message.line}:${message.column}`, 59 | message.ruleId ?? '', 60 | truncateWords(message.message, 60), 61 | ]; 62 | }), 63 | { 64 | align: ['l', 'l', 'l'], 65 | }, 66 | ); 67 | 68 | return `${head}\n${messages}\n`; 69 | }).join('\n'); 70 | 71 | return { 72 | level, 73 | text, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/package/create-package-name.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '../../environment/environment'; 2 | import { PathDetector } from '../path/path-detector'; 3 | import { PathParser } from '../path/path-parser'; 4 | 5 | export function createPackageName(sourceDir: string): string 6 | { 7 | if (Environment.getType() === 'source') 8 | { 9 | if (PathDetector.isInstallJs(sourceDir)) 10 | { 11 | const { fullExtensionTrace } = PathParser.parseInstallJs(sourceDir); 12 | 13 | return fullExtensionTrace.join('.'); 14 | } 15 | 16 | if (PathDetector.isInstallComponents(sourceDir)) 17 | { 18 | const { namespace, componentName } = PathParser.parseInstallComponents(sourceDir); 19 | 20 | return `${namespace}:${componentName}`; 21 | } 22 | 23 | if (PathDetector.isInstallTemplates(sourceDir)) 24 | { 25 | const { templateName } = PathParser.parseInstallTemplates(sourceDir); 26 | 27 | return templateName; 28 | } 29 | 30 | if (PathDetector.isInstallTemplateComponents(sourceDir)) 31 | { 32 | const { moduleName, templateName, namespace, componentName } = PathParser.parseInstallTemplateComponents(sourceDir); 33 | 34 | return `${moduleName} -> ${templateName} -> ${namespace}:${componentName}`; 35 | } 36 | 37 | if (PathDetector.isInstallActivities(sourceDir)) 38 | { 39 | const { moduleName, activityName } = PathParser.parseInstallActivity(sourceDir); 40 | 41 | return `${moduleName}.${activityName}`; 42 | } 43 | 44 | if (PathDetector.isModuleDev(sourceDir)) 45 | { 46 | const { fullTrace } = PathParser.parseModuleDev(sourceDir); 47 | 48 | return fullTrace.join('.'); 49 | } 50 | } 51 | 52 | if (Environment.getType() === 'project') 53 | { 54 | if (PathDetector.isLocalJs(sourceDir)) 55 | { 56 | const { fullExtensionTrace } = PathParser.parseLocalJs(sourceDir); 57 | 58 | return fullExtensionTrace.join('.'); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/packages/package.resolver.ts: -------------------------------------------------------------------------------- 1 | import type { BasePackage } from './base-package'; 2 | import { Environment } from '../../environment/environment'; 3 | import * as path from 'node:path'; 4 | import * as fs from 'node:fs'; 5 | import { PackageFactoryProvider } from './providers/package-factory-provider'; 6 | import {MemoryCache} from '../../utils/memory-cache'; 7 | 8 | const isExtensionName = (name: string) => { 9 | return /^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(name); 10 | }; 11 | 12 | export class PackageResolver 13 | { 14 | static #cache: MemoryCache = new MemoryCache(); 15 | 16 | static resolve(packageName: string): BasePackage | null 17 | { 18 | return this.#cache.remember(packageName, () => { 19 | if (isExtensionName(packageName)) 20 | { 21 | const segments = packageName.split('.'); 22 | const root = Environment.getRoot(); 23 | const packageFactory = PackageFactoryProvider.create(); 24 | 25 | if (Environment.getType() === 'source') 26 | { 27 | const moduleName = segments.at(0); 28 | const extensionPath = path.join(root, moduleName, 'install', 'js', ...segments); 29 | if (fs.existsSync(extensionPath)) 30 | { 31 | return packageFactory.create({ 32 | path: extensionPath, 33 | }); 34 | } 35 | } 36 | 37 | if (Environment.getType() === 'project') 38 | { 39 | const localExtensionPath = path.join(root, 'local', 'js', ...segments); 40 | if (fs.existsSync(localExtensionPath)) 41 | { 42 | return packageFactory.create({ 43 | path: localExtensionPath, 44 | }); 45 | } 46 | 47 | const productExtensionPath = path.join(root, 'bitrix', 'js', ...segments); 48 | if (fs.existsSync(productExtensionPath)) 49 | { 50 | return packageFactory.create({ 51 | path: productExtensionPath, 52 | }); 53 | } 54 | } 55 | } 56 | 57 | return null; 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/generate.tree.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { formatSize } from './format.size'; 3 | import type { DependencyNode } from '../modules/packages/types/dependency.node'; 4 | 5 | export function generateTreeString(tree: Array, prefix = ''): string 6 | { 7 | const lines: string[] = []; 8 | 9 | for (let i = 0; i < tree.length; i++) 10 | { 11 | const node = tree[i]; 12 | const isLast = i === tree.length - 1; 13 | const connector = isLast ? '└─ ' : '├─ '; 14 | const nextPrefix = prefix + (isLast ? ' ' : '│ '); 15 | 16 | const preparedKey = node.visited ? chalk.grey(node.name) : node.name; 17 | const sizes = []; 18 | 19 | if (node.bundlesSize?.js > 0) 20 | { 21 | const formattedJsSize = formatSize({ 22 | size: node.bundlesSize.js, 23 | prefix: 'js: ', 24 | }); 25 | 26 | if (node.bundlesSize.js > (100 * 1024)) 27 | { 28 | sizes.push(chalk.red(formattedJsSize)); 29 | } 30 | else if (node.bundlesSize.js > (50 * 1024)) 31 | { 32 | sizes.push(chalk.yellow(formattedJsSize)); 33 | } 34 | else 35 | { 36 | sizes.push(formattedJsSize); 37 | } 38 | } 39 | 40 | if (node.bundlesSize?.css > 0) 41 | { 42 | const formattedCssSize = formatSize({ 43 | size: node.bundlesSize.css, 44 | prefix: 'css: ', 45 | }); 46 | 47 | if (node.bundlesSize.css > (50 * 1024)) 48 | { 49 | sizes.push(chalk.red(formattedCssSize)); 50 | } 51 | else if (node.bundlesSize.css > (25 * 1024)) 52 | { 53 | sizes.push(chalk.yellow(formattedCssSize)); 54 | } 55 | else 56 | { 57 | sizes.push(formattedCssSize); 58 | } 59 | } 60 | 61 | const formattedSizes = (() => { 62 | if (sizes.length > 0) 63 | { 64 | return ` ${chalk.italic(sizes.join(', '))}`; 65 | } 66 | 67 | return ''; 68 | })(); 69 | 70 | lines.push(prefix + connector + preparedKey + formattedSizes); 71 | 72 | if (node.children && node.children.length > 0) 73 | { 74 | const childTree = generateTreeString(node.children, nextPrefix); 75 | lines.push(childTree); 76 | } 77 | } 78 | 79 | return lines.join('\n'); 80 | } 81 | -------------------------------------------------------------------------------- /src/commands/generate-tsconfig/generate-tsconfig.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { PackageFactoryProvider } from '../../modules/packages/providers/package-factory-provider'; 3 | import { findPackages } from '../../utils/package/find-packages'; 4 | import type { BasePackage } from '../../modules/packages/base-package'; 5 | import { FlexibleCompilerOptions } from '@rollup/plugin-typescript'; 6 | import * as fs from 'node:fs'; 7 | import * as path from 'path'; 8 | import { Environment } from '../../environment/environment'; 9 | import { pathOption } from '../build/options/path-option'; 10 | 11 | export const generateTsconfigCommand = new Command('generate-tsconfig'); 12 | 13 | generateTsconfigCommand 14 | .description('Generate tsconfig.json') 15 | .addOption(pathOption) 16 | .action((args): void => { 17 | const packageFactory = PackageFactoryProvider.create(); 18 | const extensionsStream: NodeJS.ReadableStream = findPackages({ 19 | startDirectory: Environment.getRoot(), 20 | packageFactory, 21 | }); 22 | 23 | const tsconfig: FlexibleCompilerOptions = { 24 | compilerOptions: { 25 | module: 'ESNext', 26 | target: 'ESNext', 27 | allowJs: true, 28 | checkJs: false, 29 | strict: true, 30 | lib: ['ESNext', 'DOM'], 31 | baseUrl: Environment.getRoot(), 32 | paths: {}, 33 | }, 34 | }; 35 | 36 | let totalCount = 0; 37 | 38 | extensionsStream 39 | .on('data', ({ extension }: { extension: BasePackage }) => { 40 | if (/^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$/.test(extension.getName())) 41 | { 42 | const relativePath = path.relative(Environment.getRoot(), extension.getInputPath()); 43 | tsconfig.compilerOptions.paths[extension.getName()] = [`./${relativePath}`]; 44 | 45 | console.log(`${extension.getName()} processed`); 46 | 47 | totalCount++; 48 | } 49 | }) 50 | .on('done', async ({ count }) => { 51 | fs.writeFileSync( 52 | path.join(Environment.getRoot(), 'tsconfig.json'), 53 | JSON.stringify(tsconfig, null, 4), 54 | ); 55 | 56 | console.log(`\n✔ tsconfig.json generated successfully. Added ${totalCount} aliases\n`); 57 | process.exit(1); 58 | }) 59 | .on('error', (err: Error) => { 60 | console.error('❌ Error while reading packages:', err); 61 | process.exit(1); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/commands/test/test-command.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Command } from 'commander'; 3 | import { parseArgValue } from '../../utils/cli/parse-arg-value'; 4 | import { preparePath } from '../../utils/cli/prepare-path'; 5 | import { PackageFactoryProvider } from '../../modules/packages/providers/package-factory-provider'; 6 | import { findPackages } from '../../utils/package/find-packages'; 7 | import { testQueue } from './queue/test-queue'; 8 | import { TaskRunner } from '../../modules/task/task'; 9 | import { runUnitTestsTask } from './tasks/run.unit.tests.task'; 10 | import { runEndToEndTestsTask } from './tasks/run.e2e.tests.task'; 11 | import type { BasePackage } from '../../modules/packages/base-package'; 12 | 13 | export const testCommand = new Command('test'); 14 | 15 | testCommand 16 | .description('Run extension tests') 17 | .option('-w, --watch', 'Watch mode. Run tests by source changes') 18 | .option('-e, --extensions ', 'Run test from specified extension', parseArgValue) 19 | .option('-m, --modules ', 'Run test from specified modules', parseArgValue) 20 | .option('-p, --path [path]', 'Run test from path', preparePath, process.cwd()) 21 | .option('--headed', 'Run in headed mode') 22 | .option('--debug', 'Run in debug mode') 23 | .option('--grep ', 'Filter tests by pattern') 24 | .action((args): void => { 25 | const packageFactory = PackageFactoryProvider.create(); 26 | const extensionsStream: NodeJS.ReadableStream = findPackages({ 27 | startDirectory: args.startDirectory, 28 | packageFactory, 29 | }); 30 | 31 | extensionsStream 32 | .on('data', ({ extension }: { extension: BasePackage }) => { 33 | void testQueue.add(async () => { 34 | const name = extension.getName(); 35 | 36 | await TaskRunner.run([ 37 | { 38 | title: chalk.bold(name), 39 | run: () => { 40 | return Promise.resolve(); 41 | }, 42 | subtasks: [ 43 | runUnitTestsTask(extension, args), 44 | runEndToEndTestsTask(extension, args), 45 | ], 46 | }, 47 | ]); 48 | }); 49 | }) 50 | .on('done', async ({ count }) => { 51 | await testQueue.onIdle(); 52 | console.log(`\n✔ Complete! For all ${count} extensions`); 53 | process.exit(1); 54 | }) 55 | .on('error', (err: Error) => { 56 | console.error('❌ Error while reading packages:', err); 57 | process.exit(1); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/commands/build/build-command.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Command } from 'commander'; 3 | 4 | import { watchOption } from './options/watch-option'; 5 | import { extensionsOption } from './options/extensions-option'; 6 | import { modulesOption } from './options/modules-option'; 7 | import { pathOption } from './options/path-option'; 8 | import { verboseOption } from './options/verbose-option'; 9 | import { forceOption } from './options/force-option'; 10 | import { buildQueue } from './queue/build-queue'; 11 | 12 | import { PackageFactoryProvider } from '../../modules/packages/providers/package-factory-provider'; 13 | import { findPackages } from '../../utils/package/find-packages'; 14 | 15 | import { TaskRunner } from '../../modules/task/task'; 16 | import { lintTask } from './tasks/lint/lint.task'; 17 | import { buildTask } from './tasks/build/build.task'; 18 | import { statisticTask } from './tasks/statistic/statistic.task'; 19 | 20 | import type { BasePackage } from '../../modules/packages/base-package'; 21 | 22 | 23 | const buildCommand = new Command('build'); 24 | 25 | buildCommand 26 | .description('Build JS extensions for Bitrix') 27 | .addOption(watchOption) 28 | .addOption(extensionsOption) 29 | .addOption(modulesOption) 30 | .addOption(pathOption) 31 | .addOption(verboseOption) 32 | .addOption(forceOption) 33 | .action(async (args) => { 34 | const packageFactory = PackageFactoryProvider.create(); 35 | const extensionsStream: NodeJS.ReadableStream = findPackages({ 36 | startDirectory: args.startDirectory, 37 | packageFactory, 38 | }); 39 | 40 | extensionsStream 41 | .on('data', ({ extension }: { extension: BasePackage }) => { 42 | buildQueue.add(async () => { 43 | const name = extension.getName(); 44 | 45 | await TaskRunner.run([ 46 | { 47 | title: chalk.bold(name), 48 | run: () => { 49 | return Promise.resolve(); 50 | }, 51 | subtasks: [ 52 | lintTask(extension, args), 53 | buildTask(extension, args), 54 | statisticTask(extension, args), 55 | ], 56 | } 57 | ]); 58 | }); 59 | }) 60 | .on('done', async ({ count }) => { 61 | await buildQueue.onIdle(); 62 | console.log(`\n✔ Complete! For all ${count} extensions`); 63 | }) 64 | .on('error', (err) => { 65 | console.error('❌ Error while reading packages:', err); 66 | process.exit(1); 67 | }); 68 | }); 69 | 70 | export { 71 | buildCommand, 72 | }; 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/commands/stat/stat-command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { extensionsOption } from './options/extensions-option'; 3 | import { modulesOptions } from './options/modules-options'; 4 | import { pathOption } from './options/path-option'; 5 | import { statQueue } from './queue/stat-queue'; 6 | import { findPackages } from '../../utils/package/find-packages'; 7 | import { PackageFactory } from '../../modules/packages/package-factory'; 8 | import { Environment } from '../../environment/environment'; 9 | import { sourceStrategies } from '../../modules/packages/strategies/source'; 10 | import { projectStrategies } from '../../modules/packages/strategies/project'; 11 | import { defaultStrategy } from '../../modules/packages/strategies/default-strategy'; 12 | import {TaskRunner} from '../../modules/task/task'; 13 | import chalk from 'chalk'; 14 | import { directDependenciesTask } from './tasks/direct.dependencies.task'; 15 | import { dependenciesTreeTask } from './tasks/dependencies.tree.task'; 16 | import { bundleSizeTask } from './tasks/bundle.size.task'; 17 | import { totalTransferredSizeTask } from './tasks/total.transferred.size.task'; 18 | 19 | const statCommand = new Command('stat'); 20 | 21 | statCommand 22 | .description('Display statistics for Bitrix extensions.') 23 | .addOption(extensionsOption) 24 | .addOption(modulesOptions) 25 | .addOption(pathOption) 26 | .action(async (args) => { 27 | const extensionsStream: NodeJS.ReadableStream = findPackages({ 28 | startDirectory: args.path, 29 | packageFactory: new PackageFactory({ 30 | strategies: Environment.getType() === 'source' ? sourceStrategies : projectStrategies, 31 | defaultStrategy: defaultStrategy, 32 | }) 33 | }); 34 | 35 | extensionsStream 36 | .on('data', ({ extension, count }) => { 37 | statQueue.add(async () => { 38 | const name = extension.getName(); 39 | 40 | await TaskRunner.run([ 41 | { 42 | title: chalk.bold(name), 43 | run: () => { 44 | return Promise.resolve(); 45 | }, 46 | }, 47 | directDependenciesTask(extension, args), 48 | dependenciesTreeTask(extension, args), 49 | bundleSizeTask(extension, args), 50 | totalTransferredSizeTask(extension, args), 51 | ]); 52 | }); 53 | }) 54 | .on('done', ({ count }) => { 55 | // console.log('\n📊 Statistics:'); 56 | // console.log(`Total extensions: ${count}`); 57 | }); 58 | }); 59 | 60 | export { 61 | statCommand, 62 | }; 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/modules/config/php/php.config.manager.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as fs from 'node:fs'; 3 | import { ConfigManager } from '../config.manager'; 4 | import * as bundleConfigStrategies from './strategies/index' 5 | import { ConfigStrategy } from '../config.strategy'; 6 | import { PhpConfigParser } from './parser/php.config.parser'; 7 | 8 | export function renderRel(rel: Array): string 9 | { 10 | return `${rel.map((item, i) => `${!i ? '\n' : ''}\t\t'${item}'`).join(',\n')}${rel.length ? ',\n\t' : ''}`; 11 | } 12 | 13 | export class PhpConfigManager extends ConfigManager<{ [key: string]: any }> 14 | { 15 | constructor() 16 | { 17 | super(); 18 | 19 | Object.values(bundleConfigStrategies).forEach((strategy: ConfigStrategy) => { 20 | this.registerStrategy(strategy.key, strategy); 21 | }); 22 | } 23 | 24 | loadFromFile(configPath: string): any 25 | { 26 | const parser = new PhpConfigParser(); 27 | const contents = fs.readFileSync(path.resolve(configPath), 'utf-8'); 28 | const result = parser.parse(contents); 29 | 30 | Object.entries(result).forEach(([key, value]) => { 31 | this.set(key, value); 32 | }); 33 | } 34 | 35 | async save(configPath: string): Promise 36 | { 37 | if (!fs.existsSync(configPath)) 38 | { 39 | fs.writeFileSync(configPath, ''); 40 | } 41 | 42 | const dependencies = this.get('rel'); 43 | if (!dependencies.includes('main.core') && !dependencies.includes('main.polyfill.core')) 44 | { 45 | dependencies.unshift('main.polyfill.core'); 46 | } 47 | 48 | // Updates dependencies list 49 | const relExp = /['"]rel['"] => (\[.*?\])(,?)/s; 50 | let configContent = fs.readFileSync(configPath, 'utf-8'); 51 | const result = configContent.match(relExp); 52 | 53 | if (Array.isArray(result) && result[1]) 54 | { 55 | const rel = `[${renderRel(dependencies)}]`; 56 | configContent = configContent.replace(result[1], rel); 57 | 58 | // Adjust skip_core 59 | const skipCoreExp = /['"]skip_core['"] => (true|false)(,?)/; 60 | const skipCoreResult = configContent.match(skipCoreExp); 61 | const skipCoreValue = !dependencies.includes('main.core'); 62 | 63 | if (Array.isArray(skipCoreResult) && skipCoreResult[1]) 64 | { 65 | configContent = configContent 66 | .replace(skipCoreExp, `'skip_core' => ${skipCoreValue},`); 67 | } 68 | else 69 | { 70 | configContent = configContent.replace( 71 | relExp, 72 | `'rel' => ${rel},\n\t'skip_core' => ${skipCoreValue},`, 73 | ); 74 | } 75 | 76 | fs.writeFileSync(configPath, configContent); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/modules/config/config.manager.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import { ConfigStrategy } from './config.strategy'; 3 | 4 | export abstract class ConfigManager 5 | { 6 | #registry: Map = new Map(); 7 | #externalConfig: Partial = {}; 8 | #virtualConfig: Partial = {}; 9 | 10 | abstract loadFromFile(configPath: string): any 11 | 12 | registerStrategy(key: string, strategy: ConfigStrategy) 13 | { 14 | this.#registry.set(key, strategy); 15 | } 16 | 17 | set(key: string, value: any) 18 | { 19 | const preparedKey = String(key).trim(); 20 | if (preparedKey.length > 0) 21 | { 22 | const strategy = this.#registry.get(key); 23 | if (strategy) 24 | { 25 | if (strategy.validate(value)) 26 | { 27 | this.#virtualConfig[key] = value; 28 | } 29 | else 30 | { 31 | throw new Error(`Invalid value of '${key}'.`); 32 | } 33 | } 34 | // else 35 | // { 36 | // console.warn(`⚠️Unknown property '${key}'. Strategy not registered`); 37 | // } 38 | } 39 | } 40 | 41 | get(key: string): any 42 | { 43 | const strategy = this.#registry.get(key); 44 | if (strategy) 45 | { 46 | if (Object.hasOwn(this.#virtualConfig, key)) 47 | { 48 | return strategy.prepare(this.#virtualConfig[key]); 49 | } 50 | 51 | if (Object.hasOwn(this.#externalConfig, key)) 52 | { 53 | return strategy.prepare(this.#externalConfig[key]); 54 | } 55 | 56 | return strategy.getDefault(); 57 | } 58 | } 59 | 60 | getAll(): T 61 | { 62 | return [...this.#registry.entries()].reduce((acc, [key]) => { 63 | return { 64 | ...acc, 65 | [key]: this.get(key), 66 | }; 67 | }, {} as T); 68 | } 69 | 70 | async save(configPath: string): Promise 71 | { 72 | const configFileExists = await (async () => { 73 | try 74 | { 75 | await fs.access(configPath, fs.constants.F_OK); 76 | return true; 77 | } 78 | catch(error) 79 | { 80 | return false; 81 | } 82 | })(); 83 | 84 | if (configFileExists) 85 | { 86 | const configContent = await fs.readFile(configPath, 'utf8'); 87 | const newConfigContent = Object.entries(this.getAll()).reduce((acc, [key, value]) => { 88 | const strategy = this.#registry.get(key); 89 | if (typeof strategy?.save === 'function') 90 | { 91 | return strategy.save(acc, value); 92 | } 93 | 94 | return acc; 95 | }, configContent); 96 | 97 | if (configContent !== newConfigContent) 98 | { 99 | await fs.writeFile(configPath, newConfigContent); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitrix/cli", 3 | "version": "4.0.0", 4 | "description": "Bitrix frontend tool", 5 | "main": "./dist/cli.js", 6 | "engines": { 7 | "node": ">=22" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "build": "tsup-node ./src/cli.ts --format esm", 12 | "test": "tsx --import=tsx/esm ./node_modules/.bin/mocha ./test/**/*.test.ts --inline-diffs --color=true", 13 | "test:watch": "tsx watch --import=tsx/esm ./node_modules/.bin/mocha ./test/**/*.test.ts --inline-diffs --color=true" 14 | }, 15 | "bin": { 16 | "bitrix": "./bin/bitrix" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/bitrix-tools/cli.git" 21 | }, 22 | "homepage": "https://github.com/bitrix-tools/cli#readme", 23 | "bugs": { 24 | "url": "https://github.com/bitrix-tools/cli/issues" 25 | }, 26 | "author": "Bitrix", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@types/babel__core": "^7.20.5", 30 | "@types/node": "^24.0.12", 31 | "tsup": "^8.5.0", 32 | "tsx": "^4.20.3", 33 | "typescript": "^5.8.3" 34 | }, 35 | "dependencies": { 36 | "@babel/core": "^7.28.3", 37 | "@babel/plugin-external-helpers": "^7.27.1", 38 | "@babel/plugin-transform-flow-strip-types": "^7.27.1", 39 | "@babel/plugin-transform-runtime": "^7.28.3", 40 | "@babel/preset-env": "^7.28.3", 41 | "@playwright/test": "^1.57.0", 42 | "@rollup/plugin-babel": "^6.0.4", 43 | "@rollup/plugin-commonjs": "^28.0.6", 44 | "@rollup/plugin-image": "^3.0.3", 45 | "@rollup/plugin-json": "^6.1.0", 46 | "@rollup/plugin-node-resolve": "^16.0.1", 47 | "@rollup/plugin-typescript": "^12.3.0", 48 | "@types/chai": "^5.2.3", 49 | "@types/mocha": "^10.0.10", 50 | "browserslist": "^4.25.4", 51 | "chai": "^6.2.1", 52 | "chalk": "^5.6.0", 53 | "commander": "^14.0.0", 54 | "eslint": "^8.57.1", 55 | "fast-glob": "^3.3.3", 56 | "log-update": "^7.0.2", 57 | "mocha": "^11.7.5", 58 | "ora": "^8.2.0", 59 | "p-queue": "^8.1.0", 60 | "php-parser": "^3.2.5", 61 | "php-writer": "^3.0.0", 62 | "playwright": "^1.57.0", 63 | "prettier": "^3.7.4", 64 | "prettier-plugin-brace-style": "^0.8.2", 65 | "rollup": "^4.49.0", 66 | "rollup-plugin-postcss": "^4.0.2", 67 | "strip-ansi": "^7.1.2", 68 | "terminal-link": "^5.0.0", 69 | "terser": "^5.43.1", 70 | "text-table": "^0.2.0", 71 | "tslib": "^2.8.1" 72 | }, 73 | "optionalDependencies": { 74 | "weak": "^1.0.1" 75 | }, 76 | "directories": { 77 | "test": "test" 78 | }, 79 | "keywords": [ 80 | "bitrix", 81 | "bitrix24", 82 | "cli", 83 | "bundler", 84 | "frontend" 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/package/build.dependencies.tree.ts: -------------------------------------------------------------------------------- 1 | import { PackageResolver } from '../../modules/packages/package.resolver'; 2 | import { BasePackage } from '../../modules/packages/base-package'; 3 | import type { DependencyNode } from '../../modules/packages/types/dependency.node'; 4 | 5 | type BuildDependenciesTreeOptions = { 6 | target: BasePackage, 7 | visited?: Set, 8 | isRoot?: boolean, 9 | size?: boolean, 10 | }; 11 | 12 | export async function buildDependenciesTree(options: BuildDependenciesTreeOptions): Promise> 13 | { 14 | const { target, isRoot, visited, size } = { 15 | visited: new Set(), 16 | isRoot: true, 17 | size: false, 18 | ...options, 19 | }; 20 | 21 | const dependencies = await target.getDependencies(); 22 | const acc: Array = []; 23 | 24 | if (isRoot) 25 | { 26 | for (const node of dependencies) 27 | { 28 | visited.add(node.name); 29 | } 30 | } 31 | 32 | for (const node of dependencies) 33 | { 34 | if (visited.has(node.name)) 35 | { 36 | acc.push({ 37 | name: node.name, 38 | visited: true, 39 | children: [], 40 | }); 41 | 42 | continue; 43 | } 44 | 45 | visited.add(node.name); 46 | 47 | const extension = PackageResolver.resolve(node.name); 48 | const children: Array = await (async () => { 49 | if (extension) 50 | { 51 | return await buildDependenciesTree({ 52 | ...options, 53 | target: extension, 54 | isRoot: false, 55 | visited, 56 | }); 57 | } 58 | 59 | return []; 60 | })(); 61 | 62 | const newNode: DependencyNode = { 63 | name: node.name, 64 | visited: false, 65 | children, 66 | }; 67 | 68 | if (size && extension) 69 | { 70 | newNode.bundlesSize = extension.getBundlesSize(); 71 | } 72 | 73 | acc.push(newNode); 74 | } 75 | 76 | if (isRoot) 77 | { 78 | const rootAcc: Array = []; 79 | for (const node of dependencies) 80 | { 81 | const extension = PackageResolver.resolve(node.name); 82 | const subTree = await (async () => { 83 | if (extension) 84 | { 85 | return await buildDependenciesTree({ 86 | ...options, 87 | target: extension, 88 | isRoot: false, 89 | visited, 90 | }); 91 | } 92 | 93 | return []; 94 | })(); 95 | 96 | const filtered: Array = []; 97 | for (const subNode of subTree) 98 | { 99 | if (!dependencies.find((dependency) => dependency.name === subNode.name)) 100 | { 101 | filtered.push(subNode); 102 | } 103 | } 104 | 105 | const newNode = { 106 | ...node, 107 | children: filtered, 108 | }; 109 | 110 | if (size && extension) 111 | { 112 | newNode.bundlesSize = extension.getBundlesSize(); 113 | } 114 | 115 | rootAcc.push(newNode); 116 | } 117 | 118 | return rootAcc; 119 | } 120 | 121 | return acc; 122 | } 123 | -------------------------------------------------------------------------------- /src/commands/test/tasks/run.unit.tests.task.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { TASK_STATUS_ICON } from '../../../modules/task/icons'; 3 | 4 | import type { BasePackage } from '../../../modules/packages/base-package'; 5 | import type { Task } from '../../../modules/task/task'; 6 | 7 | const getIndent = (indent: number) => { 8 | return ' '.repeat(indent); 9 | }; 10 | 11 | export function runUnitTestsTask(extension: BasePackage, args: Record): Task 12 | { 13 | return { 14 | title: 'Running unit tests...', 15 | run: async (context): Promise => { 16 | const testResult = await extension.runUnitTests(args); 17 | 18 | const failedTests = []; 19 | let failedCount = 0; 20 | let pendingCount = 0; 21 | let passedCount = 0; 22 | let indent = 0; 23 | let reportLines: string[] = []; 24 | 25 | let lastId = null; 26 | testResult.report.forEach((testToken) => { 27 | if (testToken.id === 'SUITE_START' && !testToken.root) 28 | { 29 | if (lastId !== 'SUITE_START') 30 | { 31 | reportLines.push(''); 32 | } 33 | 34 | indent++; 35 | reportLines.push(getIndent(indent) + `${chalk.bold(TASK_STATUS_ICON.pointerSmall)} ` + testToken.title); 36 | } 37 | 38 | if (testToken.id === 'SUITE_END' && !testToken.root) 39 | { 40 | indent--; 41 | } 42 | 43 | if (testToken.id === 'TEST_PENDING') 44 | { 45 | pendingCount++; 46 | reportLines.push(getIndent(indent) + ' ~ ' + testToken.title + chalk.gray(' (pending)')); 47 | } 48 | 49 | if (testToken.id === 'TEST_PASSED') 50 | { 51 | passedCount++; 52 | reportLines.push(getIndent(indent) + ` ${chalk.green(TASK_STATUS_ICON.success)} ` + testToken.title); 53 | } 54 | 55 | if (testToken.id === 'TEST_FAILED') 56 | { 57 | failedCount++; 58 | reportLines.push(getIndent(indent) + ` ${chalk.red(TASK_STATUS_ICON.fail)} ` + chalk.red(testToken.title)); 59 | failedTests.push(testToken); 60 | } 61 | 62 | lastId = testToken.id; 63 | }); 64 | 65 | if (failedTests.length > 0) 66 | { 67 | reportLines.push(`\n\n ${chalk.bold('Failed tests:')}`); 68 | failedTests.forEach((testToken) => { 69 | reportLines.push(` ${chalk.red(TASK_STATUS_ICON.fail)} ` + chalk.red(testToken.title)); 70 | reportLines.push(` ` + chalk.italic(testToken.error.message)); 71 | reportLines.push(testToken.diff ?? ''); 72 | }); 73 | 74 | context.fail('Unit tests failed') 75 | } 76 | 77 | reportLines.push(chalk.bold('\n\n Summary')); 78 | reportLines.push(` Passed: ${passedCount}`); 79 | reportLines.push(` Failed: ${failedCount}`); 80 | reportLines.push(` Pending: ${pendingCount}`); 81 | reportLines.push(` Total: ${passedCount + failedCount + pendingCount}`); 82 | 83 | reportLines.push(''); 84 | const detailedReport = reportLines.join('\n'); 85 | 86 | if (testResult.report.length === 0) 87 | { 88 | context.warn('No unit tests found'); 89 | } 90 | else if (failedTests.length === 0) 91 | { 92 | context.succeed('Unit tests passed'); 93 | } 94 | 95 | if (testResult.report.length > 0) 96 | { 97 | context.log(detailedReport); 98 | } 99 | 100 | return failedTests.length === 0; 101 | }, 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /src/modules/task/task.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import logUpdate from 'log-update'; 3 | import { TASK_STATUS_ICON } from './icons'; 4 | 5 | export interface TaskContext { 6 | update(message: string): void; 7 | log(message: string): void; 8 | succeed(message: string): void; 9 | fail(message: string): void; 10 | warn(message: string): void; 11 | readonly previousResult?: any; 12 | } 13 | 14 | export interface Task { 15 | title: string; 16 | run(ctx: TaskContext, result?: any): Promise; 17 | subtasks?: Task[]; 18 | } 19 | 20 | export class TaskRunner 21 | { 22 | static async run(tasks: Task[], options: { indent?: number } = {}): Promise 23 | { 24 | const indentLevel = options.indent ?? 0; 25 | return this.executeTasks(tasks, indentLevel, undefined); 26 | } 27 | 28 | private static async executeTasks( 29 | tasks: Task[], 30 | depth: number, 31 | initialResult?: any 32 | ): Promise 33 | { 34 | const indent = ' '.repeat(depth); 35 | let previousResult: any = initialResult; 36 | 37 | for (const task of tasks) 38 | { 39 | let isCompleted = false; 40 | 41 | const applyIndent = (text: string, prefix: string): string => { 42 | return text 43 | .split('\n') 44 | .map(line => prefix + line) 45 | .join('\n'); 46 | }; 47 | 48 | const ctx: TaskContext = { 49 | get previousResult() { 50 | return previousResult; 51 | }, 52 | update: (message: string) => { 53 | if (!isCompleted) 54 | { 55 | const firstLine = message.split('\n')[0]; 56 | logUpdate(applyIndent(firstLine, indent)); 57 | } 58 | }, 59 | log: (message: string) => { 60 | logUpdate.clear(); 61 | console.log(applyIndent(message, indent)); 62 | }, 63 | succeed: (message: string) => { 64 | if (!isCompleted) 65 | { 66 | isCompleted = true; 67 | logUpdate.clear(); 68 | const indented = message 69 | .split('\n') 70 | .map(line => `${indent}[${chalk.green(TASK_STATUS_ICON.success)}] ${line}`) 71 | .join('\n'); 72 | console.log(indented); 73 | } 74 | }, 75 | fail: (message: string) => { 76 | if (!isCompleted) 77 | { 78 | isCompleted = true; 79 | logUpdate.clear(); 80 | const indented = message 81 | .split('\n') 82 | .map(line => `${indent}[${chalk.red(TASK_STATUS_ICON.fail)}] ${line}`) 83 | .join('\n'); 84 | console.log(indented); 85 | } 86 | }, 87 | warn: (message: string) => { 88 | if (!isCompleted) 89 | { 90 | isCompleted = true; 91 | logUpdate.clear(); 92 | const indented = message 93 | .split('\n') 94 | .map(line => `${indent}[${chalk.yellow(TASK_STATUS_ICON.warning)}] ${line}`) 95 | .join('\n'); 96 | console.log(indented); 97 | } 98 | }, 99 | }; 100 | 101 | if (depth > 0) 102 | { 103 | ctx.update(`[${chalk.green('•')}] ${task.title}`); 104 | } 105 | else 106 | { 107 | ctx.update(task.title); 108 | } 109 | 110 | try 111 | { 112 | const result = await task.run(ctx, previousResult); 113 | previousResult = result; 114 | } 115 | catch (error: any) 116 | { 117 | ctx.fail(error.message || 'Failed'); 118 | throw error; 119 | } 120 | finally 121 | { 122 | if (!isCompleted) 123 | { 124 | logUpdate.clear(); 125 | console.log(applyIndent(task.title, indent)); 126 | } 127 | 128 | if (task.subtasks && task.subtasks.length > 0) 129 | { 130 | void await this.executeTasks(task.subtasks, depth + 1, previousResult); 131 | } 132 | } 133 | } 134 | 135 | return previousResult; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/utils/path/path-detector.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { Environment } from '../../environment/environment'; 3 | import {PathIndicators} from './path-indicators'; 4 | 5 | export class PathDetector 6 | { 7 | static #getModuleName(sourcePath: string): string 8 | { 9 | return path.relative(Environment.getRoot(), sourcePath).split(path.sep).shift(); 10 | } 11 | 12 | static isInstallJs(sourcePath: string): boolean 13 | { 14 | const moduleName = this.#getModuleName(sourcePath); 15 | const installJsIndicator = path.join(moduleName, PathIndicators.getInstallJs(), moduleName); 16 | 17 | return sourcePath.includes(installJsIndicator); 18 | } 19 | 20 | static isInstallComponents(sourcePath: string): boolean 21 | { 22 | const moduleName = this.#getModuleName(sourcePath); 23 | const installJsIndicator = path.join(moduleName, PathIndicators.getInstallComponents()); 24 | 25 | return sourcePath.includes(installJsIndicator); 26 | } 27 | 28 | static isInstallTemplates(sourcePath: string): boolean 29 | { 30 | if (PathDetector.isInstallTemplateComponents(sourcePath)) 31 | { 32 | return false; 33 | } 34 | 35 | const moduleName = this.#getModuleName(sourcePath); 36 | const installJsIndicator = path.join(moduleName, PathIndicators.getInstallTemplates()); 37 | 38 | return sourcePath.includes(installJsIndicator); 39 | } 40 | 41 | static isInstallTemplateComponents(sourcePath: string): boolean 42 | { 43 | const moduleName = this.#getModuleName(sourcePath); 44 | const installTemplatesIndicator = path.join(moduleName, PathIndicators.getInstallTemplates()); 45 | const templatePath = sourcePath.split(installTemplatesIndicator)[1]; 46 | 47 | if (templatePath) 48 | { 49 | return templatePath.includes(PathIndicators.getInstallTemplateComponents()); 50 | } 51 | 52 | return false; 53 | } 54 | 55 | static isInstallActivities(sourcePath: string): boolean 56 | { 57 | const moduleName = this.#getModuleName(sourcePath); 58 | const installJsIndicator = path.join(moduleName, PathIndicators.getInstallActivities()); 59 | 60 | return sourcePath.includes(installJsIndicator); 61 | } 62 | 63 | static isModuleDev(sourcePath: string): boolean 64 | { 65 | const moduleName = this.#getModuleName(sourcePath); 66 | const devIndicator = path.join(moduleName, PathIndicators.getModuleDev()); 67 | 68 | return sourcePath.includes(devIndicator); 69 | } 70 | 71 | static isLocalJs(sourcePath: string): boolean 72 | { 73 | return sourcePath.includes(PathIndicators.getLocalJs()); 74 | } 75 | 76 | static isLocalComponents(sourcePath: string): boolean 77 | { 78 | return sourcePath.includes(PathIndicators.getLocalComponents()); 79 | } 80 | 81 | static isLocalTemplates(sourcePath: string): boolean 82 | { 83 | return sourcePath.includes(PathIndicators.getLocalTemplates()); 84 | } 85 | 86 | static isLocalActivities(sourcePath: string): boolean 87 | { 88 | return sourcePath.includes(PathIndicators.getLocalActivities()); 89 | } 90 | 91 | static isLocalInstallJs(sourcePath: string): boolean 92 | { 93 | return ( 94 | sourcePath.includes(PathIndicators.getLocalModules()) 95 | && sourcePath.includes(PathIndicators.getInstallJs()) 96 | ); 97 | } 98 | 99 | static isLocalInstallComponents(sourcePath: string): boolean 100 | { 101 | return ( 102 | sourcePath.includes(PathIndicators.getLocalModules()) 103 | && sourcePath.includes(PathIndicators.getInstallComponents()) 104 | ); 105 | } 106 | 107 | static isLocalInstallTemplates(sourcePath: string): boolean 108 | { 109 | return ( 110 | sourcePath.includes(PathIndicators.getLocalModules()) 111 | && sourcePath.includes(PathIndicators.getInstallTemplates()) 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/modules/config/php/parser/internal/return-node-to-js-object.ts: -------------------------------------------------------------------------------- 1 | export function returnNodeToJsObject(node: any): any 2 | { 3 | if (!node) 4 | { 5 | return undefined; 6 | } 7 | 8 | switch (node.kind) 9 | { 10 | case 'string': 11 | return node.value; 12 | 13 | case 'number': 14 | return parseFloat(node.value); 15 | 16 | case 'boolean': 17 | return node.value === true || node.value === 'true'; 18 | 19 | case 'null': 20 | return null; 21 | 22 | case 'variable': 23 | const varName = node.name || 'unknown'; 24 | return { $var: '$' + varName }; 25 | 26 | case 'array': 27 | case 'short_array': 28 | const result: any = {}; 29 | const items = node.items || []; 30 | const isAssoc = items.some((item: any) => item.key != null); 31 | 32 | if (!isAssoc) 33 | { 34 | return items.map((item: any) => returnNodeToJsObject(item.value)); 35 | } 36 | 37 | for (const item of items) 38 | { 39 | const key = item.key ? returnNodeToJsObject(item.key) : Object.keys(result).length; 40 | result[key] = returnNodeToJsObject(item.value); 41 | } 42 | return result; 43 | 44 | case 'call': 45 | const what = returnNodeToJsObject(node.what); 46 | const args = (node.arguments || []).map(returnNodeToJsObject); 47 | 48 | if (what.$property || what.$static || what.$index || what.$chain || what.$staticCallResult) 49 | { 50 | const chain = what.$chain 51 | ? [...what.$chain] 52 | : what.$property 53 | ? [what.$property, what.property] 54 | : what.$static 55 | ? [what.$static, what.property] 56 | : what.$index 57 | ? [what.$index] 58 | : [what]; 59 | 60 | return { 61 | $chain: [...chain, { $call: node.what.name || 'call', args }], 62 | }; 63 | } 64 | 65 | return { 66 | $fn: returnNodeToJsObject(node.what), 67 | args, 68 | }; 69 | 70 | case 'propertylookup': 71 | const object = returnNodeToJsObject(node.what); 72 | const property = node.offset.name || returnNodeToJsObject(node.offset); 73 | 74 | const propResult = { 75 | $property: object, 76 | property: property 77 | }; 78 | 79 | if (node.next) 80 | { 81 | const next = returnNodeToJsObject(node.next); 82 | return { 83 | $chain: [object, { $property: property }], 84 | andThen: next 85 | }; 86 | } 87 | 88 | return propResult; 89 | 90 | case 'arraylookup': 91 | const array = returnNodeToJsObject(node.what); 92 | const index = returnNodeToJsObject(node.offset); 93 | 94 | const indexResult = { 95 | $index: array, 96 | index: index 97 | }; 98 | 99 | if (node.next) 100 | { 101 | return { 102 | $chain: [indexResult], 103 | andThen: returnNodeToJsObject(node.next) 104 | }; 105 | } 106 | 107 | return indexResult; 108 | 109 | case 'staticlookup': 110 | const className = returnNodeToJsObject(node.class); 111 | const offset = node.offset.name || returnNodeToJsObject(node.offset); 112 | 113 | if (node.next?.kind === 'call') 114 | { 115 | return { 116 | $staticCall: className, 117 | method: offset, 118 | args: node.next.arguments.map(returnNodeToJsObject) 119 | }; 120 | } 121 | 122 | return { 123 | $static: className, 124 | property: offset 125 | }; 126 | 127 | case 'namedargument': 128 | return { 129 | $named: node.name, 130 | value: returnNodeToJsObject(node.value) 131 | }; 132 | 133 | case 'retif': 134 | return { 135 | $coalesce: returnNodeToJsObject(node.cond), 136 | default: returnNodeToJsObject(node.else) 137 | }; 138 | 139 | case 'expr': 140 | case 'encaps': 141 | return returnNodeToJsObject(node.expr); 142 | 143 | case 'identifier': 144 | return node.name; 145 | 146 | default: 147 | return { 148 | $unknown: node.kind, 149 | raw: node, 150 | }; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/commands/flow-to-ts/flow-to-ts.command.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { Command } from 'commander'; 3 | 4 | import { PackageFactoryProvider } from '../../modules/packages/providers/package-factory-provider'; 5 | import { findPackages } from '../../utils/package/find-packages'; 6 | import { pathOption } from '../build/options/path-option'; 7 | import { convertQueue } from './queue/convert-queue'; 8 | import { TaskContext, TaskRunner } from '../../modules/task/task'; 9 | 10 | import { renameFileTask } from './tasks/rename-file.task'; 11 | import { BundleConfigManager } from '../../modules/config/bundle/bundle.config.manager'; 12 | import { convertFlowSyntaxTask } from './tasks/convert-flow-syntax.task'; 13 | 14 | import type { BasePackage } from '../../modules/packages/base-package'; 15 | 16 | export const flowToTsCommand = new Command('flow-to-ts'); 17 | 18 | flowToTsCommand 19 | .description('Migrate flow to ts') 20 | .addOption(pathOption) 21 | .option('--rm-ts', 'Remove TS source files', false) 22 | .option('--rm-js', 'Remove JS source files', false) 23 | .action((args): void => { 24 | const packageFactory = PackageFactoryProvider.create(); 25 | const extensionsStream: NodeJS.ReadableStream = findPackages({ 26 | startDirectory: args.path, 27 | packageFactory, 28 | }); 29 | 30 | extensionsStream 31 | .on('data', async ({ extension }: { extension: BasePackage }) => { 32 | void convertQueue.add(async () => { 33 | const sourceFiles = extension.getActualSourceFiles(); 34 | if (sourceFiles.length === 0) 35 | { 36 | console.log('Source JS files don\'t exist.'); 37 | } 38 | 39 | await TaskRunner.run([ 40 | { 41 | title: chalk.bold(`Migrate ${extension.getName()} to TypeScript`), 42 | run: async () => { 43 | return Promise.resolve(); 44 | }, 45 | subtasks: [ 46 | { 47 | title: 'Rename source files with `hg rename`', 48 | run: async () => { 49 | return Promise.resolve(); 50 | }, 51 | subtasks: sourceFiles.map((filePath: string) => { 52 | return renameFileTask(extension, filePath); 53 | }), 54 | }, 55 | { 56 | title: 'Convert Flow.js syntax to TypeScript syntax', 57 | run: async () => { 58 | return Promise.resolve(); 59 | }, 60 | subtasks: sourceFiles.map((filePath: string) => { 61 | return convertFlowSyntaxTask(extension, filePath.replace(/\.js$/, '.ts')); 62 | }), 63 | }, 64 | { 65 | title: 'Update bundle.config.js', 66 | run: async (context: TaskContext) => { 67 | return Promise.resolve(); 68 | }, 69 | subtasks: [ 70 | { 71 | title: 'Change entry point...', 72 | run: async (context: TaskContext) => { 73 | const bundleConfig: BundleConfigManager = extension.getBundleConfig(); 74 | const input = bundleConfig.get('input'); 75 | 76 | if (typeof input === 'string') 77 | { 78 | const tsEntryPoint = input.replace(/\.js$/, '.ts'); 79 | bundleConfig.set('input', tsEntryPoint); 80 | 81 | await bundleConfig.save(extension.getBundleConfigFilePath()); 82 | 83 | context.succeed(`Entry point changed to ${tsEntryPoint}`); 84 | } 85 | else 86 | { 87 | context.warn(`Entry point not set`); 88 | } 89 | }, 90 | }, 91 | ], 92 | }, 93 | ], 94 | } 95 | ]); 96 | }); 97 | }) 98 | .on('done', async ({ count }) => { 99 | await convertQueue.onIdle(); 100 | process.exit(1); 101 | }) 102 | .on('error', (err: Error) => { 103 | console.error('❌ Error while reading packages:', err); 104 | process.exit(1); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/utils/path/path-parser.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { PathIndicators } from './path-indicators'; 3 | import { Environment } from '../../environment/environment'; 4 | 5 | export class PathParser 6 | { 7 | static #getModuleName(sourceDir: string): string 8 | { 9 | return path.relative(Environment.getRoot(), sourceDir).split(path.sep).shift(); 10 | } 11 | 12 | static parseInstallJs(sourceDir: string): { 13 | moduleName: string, 14 | extensionTrace: Array, 15 | fullExtensionTrace: Array, 16 | } 17 | { 18 | const [, extensionDir] = sourceDir.split(PathIndicators.getInstallJs()); 19 | const [moduleName, ...extensionTrace] = extensionDir.split(path.sep); 20 | const fullExtensionTrace = [moduleName, ...extensionTrace]; 21 | 22 | return { 23 | moduleName, 24 | extensionTrace, 25 | fullExtensionTrace, 26 | }; 27 | } 28 | 29 | static parseInstallComponents(sourceDir: string): { 30 | moduleName: string, 31 | namespace: string, 32 | componentName: string, 33 | } 34 | { 35 | const moduleName = this.#getModuleName(sourceDir); 36 | const [, namespaceDir] = sourceDir.split(PathIndicators.getInstallComponents()); 37 | const [namespace, componentName] = namespaceDir.split(path.sep); 38 | 39 | return { 40 | moduleName, 41 | namespace, 42 | componentName, 43 | }; 44 | } 45 | 46 | static parseInstallTemplates(sourceDir: string): { 47 | moduleName: string, 48 | templateName: string, 49 | restTrace: Array, 50 | restPath: string, 51 | } 52 | { 53 | const moduleName = this.#getModuleName(sourceDir); 54 | const [, templateDir ] = sourceDir.split(PathIndicators.getInstallTemplates()); 55 | const [templateName, ...restTrace] = templateDir.split(path.sep); 56 | const restPath = path.join(path.sep, ...restTrace); 57 | 58 | return { 59 | moduleName, 60 | templateName, 61 | restTrace, 62 | restPath, 63 | }; 64 | } 65 | 66 | static parseInstallTemplateComponents(sourceDir: string): { 67 | moduleName: string, 68 | templateName: string, 69 | namespace: string, 70 | componentName: string, 71 | } 72 | { 73 | const moduleName = this.#getModuleName(sourceDir); 74 | const { templateName, restPath } = PathParser.parseInstallTemplates(sourceDir); 75 | const [, namespacePath] = restPath.split(PathIndicators.getInstallTemplateComponents()); 76 | const segments = namespacePath.split(path.sep); 77 | const namespace = segments.shift(); 78 | const componentName = segments.join(path.sep); 79 | 80 | return { 81 | moduleName, 82 | templateName, 83 | namespace, 84 | componentName, 85 | }; 86 | } 87 | 88 | static parseInstallActivity(sourceDir: string): { 89 | namespace: string, 90 | moduleName: string, 91 | activityName: string, 92 | } 93 | { 94 | const moduleName = this.#getModuleName(sourceDir); 95 | const [, activityPath] = sourceDir.split(PathIndicators.getInstallActivities()); 96 | const [namespace, activityName] = activityPath.split(path.sep); 97 | 98 | return { 99 | moduleName, 100 | namespace, 101 | activityName, 102 | }; 103 | } 104 | 105 | static parseModuleDev(sourceDir: string): { 106 | moduleName: string, 107 | fullTrace: Array, 108 | } 109 | { 110 | const moduleName = this.#getModuleName(sourceDir); 111 | const fullTrace = path.relative(Environment.getRoot(), sourceDir).split(path.sep); 112 | 113 | return { 114 | moduleName, 115 | fullTrace, 116 | }; 117 | } 118 | 119 | static parseLocalJs(sourceDir: string): { 120 | moduleName: string, 121 | extensionTrace: Array, 122 | fullExtensionTrace: Array, 123 | } 124 | { 125 | const [, extensionDir] = sourceDir.split(PathIndicators.getLocalJs()); 126 | const [moduleName, ...extensionTrace] = extensionDir.split(path.sep); 127 | const fullExtensionTrace = [moduleName, ...extensionTrace]; 128 | 129 | return { 130 | moduleName, 131 | extensionTrace, 132 | fullExtensionTrace, 133 | }; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utils/flow-to-ts.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import * as parser from '@babel/parser'; 3 | import * as prettier from 'prettier'; 4 | import traverse, { NodePath } from '@babel/traverse'; 5 | import generate from '@babel/generator'; 6 | 7 | export async function convertFlowToTs(code: string): Promise 8 | { 9 | let ast; 10 | try 11 | { 12 | ast = parser.parse(code, { 13 | sourceType: 'module', 14 | plugins: [ 15 | 'flow', 16 | ], 17 | }); 18 | } 19 | catch (error) 20 | { 21 | console.error('Babel parser error:', error); 22 | return code; 23 | } 24 | 25 | traverse(ast, { 26 | // Flow comments 27 | Program(path) { 28 | path.node.body.forEach((node) => { 29 | if (node.leadingComments) 30 | { 31 | node.leadingComments = node.leadingComments.filter((comment) => { 32 | const value = comment.value.trim(); 33 | return !(value.includes("@flow") || value.includes("$FlowIssue")); 34 | }); 35 | } 36 | 37 | if (node.trailingComments) 38 | { 39 | node.trailingComments = node.trailingComments.filter((comment) => { 40 | const value = comment.value.trim(); 41 | return !(value.includes("@flow") || value.includes("$FlowIssue")); 42 | }); 43 | } 44 | 45 | if (node.leadingComments) 46 | { 47 | for (const comment of node.leadingComments) 48 | { 49 | comment.value = comment.value 50 | .replace(/\$(FlowFixMe|FlowExpectError)/g, "@ts-expect-error") 51 | .replace(/\$FlowIgnore/g, "@ts-ignore"); 52 | } 53 | } 54 | 55 | if (node.trailingComments) 56 | { 57 | for (const comment of node.trailingComments) 58 | { 59 | comment.value = comment.value 60 | .replace(/\$(FlowFixMe|FlowExpectError)/g, "@ts-expect-error") 61 | .replace(/\$FlowIgnore/g, "@ts-ignore"); 62 | } 63 | } 64 | }); 65 | }, 66 | 67 | // Typeof import 68 | ImportSpecifier({ node }) { 69 | if (node.importKind === 'typeof') 70 | { 71 | node.importKind = 'type'; 72 | } 73 | }, 74 | ImportDeclaration({ node }) { 75 | if (node.importKind === 'typeof') 76 | { 77 | node.importKind = 'type'; 78 | } 79 | }, 80 | 81 | // * type annotation 82 | ExistsTypeAnnotation(path) { 83 | path.replaceWith(t.anyTypeAnnotation()); 84 | }, 85 | 86 | // covariant / contravariant 87 | ClassProperty({ node }) { 88 | if (node.variance && node.variance.kind === 'plus') 89 | { 90 | node.readonly = true; 91 | } 92 | 93 | delete node.variance; 94 | }, 95 | 96 | // opaque type 97 | OpaqueType(path: NodePath) { 98 | const id = path.node.id; 99 | const typeAnnotation = path.node.impltype; 100 | const replacement = t.typeAlias(id, null, typeAnnotation); 101 | path.replaceWith(replacement); 102 | }, 103 | 104 | // flow generic types 105 | GenericTypeAnnotation(path: NodePath) { 106 | const typeName = path.node.id.name; 107 | 108 | if (typeName === '$Exact') 109 | { 110 | if (path.node.typeParameters && path.node.typeParameters.params.length === 1) 111 | { 112 | path.replaceWith(path.node.typeParameters.params[0]); 113 | } 114 | } 115 | else if (typeName === '$Shape') 116 | { 117 | path.node.id.name = 'Partial'; 118 | } 119 | else if (typeName === '$ReadOnly') 120 | { 121 | path.node.id.name = 'Readonly'; 122 | } 123 | else if (typeName === '$ReadOnlyArray') 124 | { 125 | path.node.id.name = 'ReadonlyArray'; 126 | } 127 | }, 128 | 129 | // Nullable types 130 | NullableTypeAnnotation: { 131 | exit(path) { 132 | const { typeAnnotation } = path.node; 133 | 134 | path.replaceWith( 135 | t.unionTypeAnnotation([ 136 | typeAnnotation, 137 | t.nullLiteralTypeAnnotation(), 138 | t.voidTypeAnnotation(), 139 | ]) 140 | ); 141 | }, 142 | }, 143 | 144 | ArrayPattern(path) 145 | { 146 | path.node.elements.forEach((element) => { 147 | if (element && element.type === 'Identifier' && element.typeAnnotation) 148 | { 149 | delete element.typeAnnotation; 150 | } 151 | 152 | if (element && element.type === 'AssignmentPattern' && element.left) 153 | { 154 | const leftSide = element.left; 155 | if (leftSide.type === 'Identifier' && leftSide.typeAnnotation) 156 | { 157 | delete leftSide.typeAnnotation; 158 | } 159 | } 160 | }); 161 | }, 162 | }); 163 | 164 | const result = generate(ast, { 165 | retainLines: true, 166 | compact: false, 167 | }, code); 168 | 169 | let generatedCode = result.code; 170 | 171 | generatedCode = generatedCode.replace(/type\n/g, 'type '); 172 | 173 | generatedCode = await prettier.format( 174 | generatedCode, 175 | { 176 | parser: 'typescript', 177 | 178 | useTabs: true, 179 | singleQuote: true, 180 | trailingComma: 'all', 181 | plugins: [ 182 | await import('prettier-plugin-brace-style'), 183 | ], 184 | braceStyle: 'allman', 185 | arrowParens: 'always', 186 | printWidth: 120, 187 | }, 188 | ); 189 | 190 | generatedCode = generatedCode.trim(); 191 | 192 | generatedCode = generatedCode.replace(/=>\s+/g, '=> '); 193 | 194 | const regexVoidToUndefined = /(\w+)\s+\|\snull\s\|\svoid/g; 195 | generatedCode = generatedCode.replace(regexVoidToUndefined, '$1 | null | undefined'); 196 | 197 | return generatedCode; 198 | } 199 | -------------------------------------------------------------------------------- /test/utils/flow-to-ts/flow-to-ts.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import { code } from '../../test-utils/code'; 4 | import { convertFlowToTs } from '../../../src/utils/flow-to-ts'; 5 | 6 | describe('utils/flow-to-ts', () => { 7 | it('Should remove @flow leading comment', async () => { 8 | const source = code` 9 | // @flow 10 | // // @flow 11 | 12 | import { Type } from 'main.core'; 13 | `; 14 | 15 | const converted = await convertFlowToTs(source); 16 | 17 | assert.equal( 18 | converted, 19 | code` 20 | import { Type } from 'main.core'; 21 | `, 22 | ); 23 | }); 24 | 25 | it('Should convert $FlowFixMe, $FlowIgnore, $FlowExpectError comments to @ts-expect-error, @ts-ignore', async () => { 26 | const source = code` 27 | // $FlowFixMe 28 | export class TestFlow {} 29 | // $FlowIgnore 30 | export class TestFlow2 {} 31 | // $FlowExpectError 32 | export class TestFlow3 {} 33 | `; 34 | 35 | const converted = await convertFlowToTs(source); 36 | 37 | assert.equal( 38 | converted, 39 | code` 40 | // @ts-expect-error 41 | export class TestFlow 42 | {} 43 | // @ts-ignore 44 | export class TestFlow2 45 | {} 46 | // @ts-expect-error 47 | export class TestFlow3 48 | {} 49 | `, 50 | ); 51 | }); 52 | 53 | it('Should convert import typeof to import type', async () => { 54 | const source = code` 55 | import typeof Type from 'main.core'; 56 | import typeof { Type2 } from 'main.core'; 57 | import { typeof Type3 } from 'main.core'; 58 | import { 59 | typeof Type4, 60 | typeof Runtime, 61 | Tag, 62 | Dom, 63 | typeof Reflection, 64 | } from 'main.core'; 65 | `; 66 | 67 | const converted = await convertFlowToTs(source); 68 | 69 | assert.equal( 70 | converted, 71 | code` 72 | import type Type from 'main.core'; 73 | import type { Type2 } from 'main.core'; 74 | import { type Type3 } from 'main.core'; 75 | import { type Type4, type Runtime, Tag, Dom, type Reflection } from 'main.core'; 76 | `, 77 | ); 78 | }); 79 | 80 | it('Should convert * type annotation to any', async () => { 81 | const source = code` 82 | export function testFlow(): * 83 | { 84 | return 222; 85 | } 86 | 87 | const name: * = 'name'; 88 | type TestType = { 89 | name: *; 90 | id: number; 91 | }; 92 | `; 93 | 94 | const converted = await convertFlowToTs(source); 95 | 96 | assert.equal( 97 | converted, 98 | code` 99 | export function testFlow(): any 100 | { 101 | return 222; 102 | } 103 | 104 | const name: any = 'name'; 105 | type TestType = { 106 | name: any; 107 | id: number; 108 | }; 109 | `, 110 | ); 111 | }); 112 | 113 | it('Should convert covariant (+) and contravariant (-) modifiers to readonly', async () => { 114 | const source = code` 115 | export class TestFlow4 116 | { 117 | +covariant: string = 'testFlow'; 118 | -contravariant: string = 'testFlow2'; 119 | 120 | static +staticCovariant: string = 'testFlow3'; 121 | static -contravariant: string = 'testFlow4'; 122 | } 123 | `; 124 | 125 | const converted = await convertFlowToTs(source); 126 | 127 | assert.equal( 128 | converted, 129 | code` 130 | export class TestFlow4 131 | { 132 | readonly covariant: string = 'testFlow'; 133 | contravariant: string = 'testFlow2'; 134 | 135 | static readonly staticCovariant: string = 'testFlow3'; 136 | static contravariant: string = 'testFlow4'; 137 | } 138 | `, 139 | ); 140 | }); 141 | 142 | it('Should convert opaque type to type alias', async () => { 143 | const source = code` 144 | opaque type Interval = [number, number]; 145 | opaque type Interval2 = { 146 | name: string, 147 | interval: number, 148 | }; 149 | 150 | export opaque type IncludeBoundariesValue = 'all' | 'left' | 'right' | 'none'; 151 | `; 152 | 153 | const converted = await convertFlowToTs(source); 154 | 155 | assert.equal( 156 | converted, 157 | code` 158 | type Interval = [number, number]; 159 | type Interval2 = { 160 | name: string; 161 | interval: number; 162 | }; 163 | 164 | export type IncludeBoundariesValue = 'all' | 'left' | 'right' | 'none'; 165 | `, 166 | ); 167 | }); 168 | 169 | it('Should convert Flow utility types ($Exact, $Shape, $ReadOnly, $ReadOnlyArray) to TypeScript equivalents', async () => { 170 | const source = code` 171 | const uType1: $Exact = {}; 172 | function uType2(name: $Exact): $Exact 173 | {} 174 | function uType3(name: $Exact, test: number): $Exact 175 | {} 176 | 177 | const uType4: $Shape = {}; 178 | function uType5(name: $Shape): $Shape 179 | {} 180 | function uType6(name: $Shape, test: string): $Shape 181 | {} 182 | 183 | const uType7: $ReadOnly = {}; 184 | function uType8(name: $ReadOnly): $ReadOnly 185 | {} 186 | function uType9(name: $ReadOnly, test: string): $ReadOnly 187 | {} 188 | 189 | const uType10: $ReadOnlyArray = []; 190 | `; 191 | 192 | const converted = await convertFlowToTs(source); 193 | 194 | assert.equal( 195 | converted, 196 | code` 197 | const uType1: TestFlow = {}; 198 | function uType2(name: TestFlow): TestFlow 199 | {} 200 | function uType3(name: TestFlow, test: number): TestFlow 201 | {} 202 | 203 | const uType4: Partial = {}; 204 | function uType5(name: Partial): Partial 205 | {} 206 | function uType6(name: Partial, test: string): Partial 207 | {} 208 | 209 | const uType7: Readonly = {}; 210 | function uType8(name: Readonly): Readonly 211 | {} 212 | function uType9(name: Readonly, test: string): Readonly 213 | {} 214 | 215 | const uType10: ReadonlyArray = []; 216 | `, 217 | ); 218 | }); 219 | 220 | it('Should convert nullable type annotation (?T) to T | null | undefined', async () => { 221 | const source = code` 222 | function test(): ?MyType 223 | {} 224 | 225 | const test2: ?MyType = null; 226 | 227 | type CustomType = { 228 | test: ?MyType, 229 | }; 230 | 231 | const arr = (param: ?MyType): ?MyType => {}; 232 | const arr2 = (param: ?MyType): ?MyType | TestType => {}; 233 | const arr3 = (param: ?MyType | Type): Type | ?MyType | TestType => {}; 234 | `; 235 | 236 | const converted = await convertFlowToTs(source); 237 | 238 | assert.equal( 239 | converted, 240 | code` 241 | function test(): MyType | null | undefined 242 | {} 243 | 244 | const test2: MyType | null | undefined = null; 245 | 246 | type CustomType = { 247 | test: MyType | null | undefined; 248 | }; 249 | 250 | const arr = (param: MyType | null | undefined): MyType | null | undefined => {}; 251 | const arr2 = (param: MyType | null | undefined): (MyType | null | undefined) | TestType => {}; 252 | const arr3 = (param: (MyType | null | undefined) | Type): Type | (MyType | null | undefined) | TestType => {}; 253 | `, 254 | ); 255 | }); 256 | 257 | it('Should remove type annotations from array destructuring pattern', async () => { 258 | const source = code` 259 | const [key: string, value: string = ''] = prop; 260 | `; 261 | 262 | const converted = await convertFlowToTs(source); 263 | 264 | assert.equal( 265 | converted, 266 | code` 267 | const [key, value = ''] = prop; 268 | `, 269 | ); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @bitrix/cli 2 | @bitrix/cli — консольный инструмент Битрикс-разработчика, 3 | основная цель — упростить и автоматизировать разработку фронтенда для 4 | проектов на «Битрикс Управление Сайтом» и «Битрикс24». 5 | 6 | [![npm version](https://badge.fury.io/js/%40bitrix%2Fcli.svg)](https://badge.fury.io/js/%40bitrix%2Fcli) 7 | 8 | 9 | ## Содержание 10 | 1. [Описание](#introduction) 11 | 2. [Установка](#install) 12 | 3. [Конфигурация](#config) 13 | 4. [Сборка](#build) 14 | 5. [Запуск тестов](#test) 15 | 6. [Создание экстеншна](#create) 16 | 17 |

Описание

18 | 19 | @bitrix/cli — это набор консольных команд 20 | 1. `bitrix build` для сборки и транспиляции ES6+ кода 21 | 2. `bitrix test` для запуска Mocha тестов 22 | 3. `bitrix create` для быстрого создания «экстеншна» 23 | 24 | > В первую очередь, `@bitrix/cli` предназначен для работы с «экстеншнами», 25 | шаблонами сайта и шаблонами компонентов. 26 | 27 | 28 |

Установка

29 | 30 | NPM 31 | ```bash 32 | $ npm install -g @bitrix/cli 33 | ``` 34 | 35 | YARN 36 | ```bash 37 | $ yarn global add @bitrix/cli 38 | ``` 39 | 40 |

Конфигурация

41 | 42 | ### Базовая конфигурация 43 | ```javascript 44 | module.exports = { 45 | input: './app.js', 46 | output: './dist/app.bundle.js', 47 | }; 48 | ``` 49 | 50 | ### Все параметры 51 | ```javascript 52 | module.exports = { 53 | // Файл для которого необходимо выполнить сборку. 54 | // Необходимо указать относительный путь 55 | input: string, 56 | 57 | // Путь к бандлу, который будет создан в результате сборки 58 | // Обычно это ./dist/.bundle.js 59 | // Необходимо указать относительный путь 60 | output: string || {js: string, css: string}, 61 | 62 | // Неймспейс, в который будут добавлены все экспорты из файла указанного в input 63 | // Например 'BX.Main.Filter' 64 | namespace: string, 65 | 66 | // Списки файлов для принудительного объединения. 67 | // Файлы будут объединены без проверок на дублирование кода. 68 | // sourcemap's объединяются автоматически 69 | // Необходимо указать относительные пути 70 | concat: { 71 | js: Array, 72 | css: Array, 73 | }, 74 | 75 | // Разрешает или запрещает сборщику модифицировать config.php 76 | // По умолчанию true (разрешено) 77 | adjustConfigPhp: boolean, 78 | 79 | // Разрешает или запрещает сборщику удалять неиспользуемый код. 80 | // По умолчанию true (включено). 81 | treeshake: boolean, 82 | 83 | // Разрешает или запрещает пересобирать бандлы 84 | // если сборка запущена не в корне текущего экстеншна 85 | // По умолчанию `false` (разрешено) 86 | 'protected': boolean, 87 | 88 | plugins: { 89 | // Переопределяет параметры Babel. 90 | // Можно указать собственные параметры Babel 91 | // https://babeljs.io/docs/en/options 92 | // Если указать false, то код будет собран без транспиляции 93 | babel: boolean | Object, 94 | 95 | // Дополнительные плагины Rollup, 96 | // которые будут выполняться при сборке бандлов 97 | custom: Array, 98 | }, 99 | // Определяет правила обработки путей к изображениям в CSS 100 | // Доступно с версии 3.0.0 101 | cssImages: { 102 | // Определяет правило по которому изображения должны быть обработаны 103 | // 'inline' — преобразует изображения в инлайн 104 | // 'copy' — копирует изображения в директорию 'output' 105 | // По умолчанию 'inline'. 106 | type: 'inline' | 'copy', 107 | 108 | // Путь к директории в которую должны быть скопированы используемые изображения 109 | output: string, 110 | 111 | // Максимальный размер изображений в кб, которые могут быть преобразованы в инлайн 112 | // По умолчанию 14кб 113 | maxSize: number, 114 | 115 | // Использовать ли svgo для оптимизации svg 116 | // По умолчанию true 117 | svgo: boolean, 118 | }, 119 | resolveFilesImport: { 120 | // Путь к директории в которую должны быть скопированы импортированные изображения 121 | output: string, 122 | 123 | // Определяет разрешенные для импорта типы файлов 124 | // По умолчанию ['**/*.svg', '**/*.png', '**/*.jpg', '**/*.gif'] 125 | // https://github.com/isaacs/minimatch 126 | include: Array, 127 | 128 | // По умолчанию [] 129 | exclude: Array, 130 | }, 131 | 132 | // Определяет правила Browserslist 133 | // false — не использовать (по умолчанию) 134 | // true — использовать файл .browserslist / .browserslistrc 135 | browserslist: boolean | string | Array, 136 | 137 | // Включает или отключает минификацию 138 | // По умолчанию отключено 139 | // Может принимать объект настроек Terser 140 | // false — не минифицировать (по умолчанию) 141 | // true — минифицировать с настройками по умолчанию 142 | // object — минифицировать с указанными настройками 143 | minification: boolean | object, 144 | 145 | // Включает или отключает преобразование нативных JS классов 146 | // По умолчанию значенение параметра выставляется автоматически на основании browserslist 147 | transformClasses: boolean, 148 | 149 | // Включает или отключает создание Source Maps файлов 150 | sourceMaps: boolean, 151 | 152 | // Настройки тестов 153 | tests: { 154 | // Настройки локализации 155 | localization: { 156 | // Код языка локализации. По умолчаниию 'en' 157 | languageId: string, 158 | // Включает или выключает автозагрузку фраз в тестах. По умолчанию включено 159 | autoLoad: boolean, 160 | }, 161 | }, 162 | }; 163 | ``` 164 | 165 |

Сборка

166 | 167 | Для запуска сборки выполните команду 168 | ```bash 169 | $ bitrix build 170 | ``` 171 | > Сборщик рекурсивно найдет все файлы `bundle.config.js` и выполнит 172 | для каждого конфига сборку и транспиляцию. 173 | 174 | ### Дополнительные параметры 175 | 176 | #### --watch [\[, ...]], -w=[\[, ...]] 177 | Режим отслеживания изменений. Пересобирает бандлы после изменения исходных файлов. 178 | В качестве значения можно указать список расширений файлов, в которых нужно отслеживать изменения. 179 | ```bash 180 | $ bitrix build --watch 181 | ``` 182 | Сокращенный вариант 183 | ```bash 184 | $ bitrix build -w 185 | ``` 186 | Вариант с отслеживанием изменений в указанных типах файлов 187 | ```bash 188 | $ bitrix build -w=defaults,json,mjs,svg 189 | ``` 190 | > `defaults` — набор расширений файлов которые отслеживаются по умолчанию. 191 | Он равен `js,jsx,vue,css,scss`. 192 | 193 | #### --test, -t 194 | Режим непрерывного тестирования. Тесты запускаются после каждой сборки. 195 | Обратите внимание, сборка с параметром `--test` выводит в отчете только статус прохождения 196 | тестов — прошли или не прошли, полный отчет выводит только команда `bitrix test`. 197 | ```bash 198 | $ bitrix build --test 199 | ``` 200 | 201 | #### --modules \[, ...], -m=\[, ...] 202 | Сборка только указанных модулей. Параметр поддерживается только в корневой с модулями `local/js` и `bitrix/modules`. 203 | В значении укажите имена модулей через запятую, например: 204 | ```bash 205 | $ bitrix build --modules main,ui,landing 206 | ``` 207 | 208 | #### --path \, -p=\ 209 | Запуск сборки для указанной директории. В значении укажите относительный путь к директории, 210 | например: 211 | ```bash 212 | $ bitrix build --path ./main/install/js/main/loader 213 | ``` 214 | Сокращенный вариант 215 | ```bash 216 | $ bitrix build -p=./main/install/js/main/loader 217 | ``` 218 | 219 | #### --extensions \[, ...], -e=\[, ...] 220 | Запускает сборку указанных экстеншнов. В качестве значения нужно указать имя экстеншна, 221 | либо список имен через запятую. Команду можно запускать из любой директории проекта. 222 | ```bash 223 | $ bitrix build -e=main.core,ui.buttons,landing.main 224 | ``` 225 | 226 | 227 |

Запуск тестов

228 | ```bash 229 | $ bitrix test 230 | ``` 231 | Команда запускает Mocha тесты и выводит подробный отчет о прохождении тестов. 232 | > Тестами считаются JS файлы, расположенные в директории `./test`, 233 | относительно файла `bundle.config.js`. В момент запуска тестов исходный код и код тестов, 234 | налету обрабатывается сборщиком и после чего выполняется. Поэтому тесты можно писать на ES6+ 235 | 236 | ### Дополнительные параметры 237 | 238 | #### --watch [\[, ...]], -w=[\[, ...]] 239 | Режим отслеживания изменений. Запускает тесты после изменения исходных файлов и кода тестов. 240 | В качестве значения можно указать список расширений файлов, в которых нужно отслеживать изменения. 241 | ```bash 242 | $ bitrix test --watch 243 | ``` 244 | Сокращенный вариант 245 | ```bash 246 | $ bitrix test -w 247 | ``` 248 | Вариант с отслеживанием изменений в указанных типах файлов 249 | ```bash 250 | $ bitrix test -w=defaults,json,mjs,svg 251 | ``` 252 | > `defaults` — набор расширений файлов которые отслеживаются по умолчанию. 253 | Он равен `js,jsx,vue,css,scss`. 254 | 255 | #### --modules \[, ...], -m=\[, ...] 256 | Тестирование только указанных модулей. Параметр поддерживается только в 257 | корневой директории репозитория. В значении укажите имена модулей через запятую, 258 | например: 259 | ```bash 260 | $ bitrix test --modules main,ui,landing 261 | ``` 262 | 263 | #### --path \, -p=\ 264 | Запуск тестов для указанной директории. В значении укажите относительный путь к директории, 265 | например: 266 | ```bash 267 | $ bitrix test --path ./main/install/js/main/loader 268 | ``` 269 | Сокращенный вариант 270 | ```bash 271 | $ bitrix test -p=./main/install/js/main/loader 272 | ``` 273 | 274 | #### --extensions \[, ...], -e=\[, ...] 275 | Запускает тесты в указанных экстеншнах. В качестве значения нужно указать имя экстеншна, 276 | либо список имен через запятую. Команду можно запускать из любой директории проекта. 277 | ```bash 278 | $ bitrix test -e=main.core,ui.buttons,landing.main 279 | ``` 280 | 281 |

Создание «экстеншна»

282 | 283 | Для создания «экстеншна» 284 | 1. Перейдите в директорию `local/js/{module}` 285 | 2. Выполните команду `bitrix create` 286 | 3. Ответьте на вопросы мастера 287 | -------------------------------------------------------------------------------- /src/modules/packages/base-package.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as fs from 'node:fs'; 3 | import fg from 'fast-glob'; 4 | import { spawn } from 'node:child_process'; 5 | 6 | import playwright from 'playwright'; 7 | import browserslist from 'browserslist'; 8 | import { ESLint } from 'eslint'; 9 | 10 | import { babel } from '@rollup/plugin-babel'; 11 | import nodeResolve from '@rollup/plugin-node-resolve'; 12 | import commonjs from '@rollup/plugin-commonjs'; 13 | import presetEnv from '@babel/preset-env'; 14 | import flowStripTypesPlugin from '@babel/plugin-transform-flow-strip-types'; 15 | import externalHelpersPlugin from '@babel/plugin-external-helpers'; 16 | 17 | import { rollup, type InputOptions, type OutputOptions, type RollupLog, type LoggingFunction, OutputChunk } from 'rollup'; 18 | import postcss from 'rollup-plugin-postcss'; 19 | import jsonPlugin from '@rollup/plugin-json'; 20 | import imagePlugin from '@rollup/plugin-image'; 21 | import typescript, { FlexibleCompilerOptions } from '@rollup/plugin-typescript'; 22 | 23 | import { BundleConfigManager } from '../config/bundle/bundle.config.manager'; 24 | import { PhpConfigManager } from '../config/php/php.config.manager'; 25 | import { MemoryCache } from '../../utils/memory-cache'; 26 | import { isExternalDependencyName } from '../../utils/is.external.dependency.name'; 27 | import { PackageResolver } from './package.resolver'; 28 | import { LintResult } from '../linter/lint.result'; 29 | import { Environment } from '../../environment/environment'; 30 | import { flattenTree } from '../../utils/flatten.tree'; 31 | import { buildDependenciesTree } from '../../utils/package/build.dependencies.tree'; 32 | import type { DependencyNode } from './types/dependency.node'; 33 | 34 | type BasePackageOptions = { 35 | path: string, 36 | }; 37 | 38 | export abstract class BasePackage 39 | { 40 | static TYPESCRIPT_EXTENSION = 'ts'; 41 | static JAVASCRIPT_EXTENSION = 'js'; 42 | static SOURCE_FILES_PATTERN: Array = [ 43 | `**/*.${BasePackage.JAVASCRIPT_EXTENSION}`, 44 | `**/*.${BasePackage.TYPESCRIPT_EXTENSION}`, 45 | ]; 46 | 47 | readonly #path: string; 48 | readonly #cache: MemoryCache = new MemoryCache(); 49 | readonly #warnings: Set = new Set(); 50 | readonly #errors: Set = new Set(); 51 | readonly #externalDependencies: Array = []; 52 | 53 | constructor(options: BasePackageOptions) 54 | { 55 | this.#path = options.path; 56 | } 57 | 58 | getPath(): string 59 | { 60 | return this.#path; 61 | } 62 | 63 | getBundleConfigFilePath(): string 64 | { 65 | return path.join(this.getPath(), 'bundle.config.js'); 66 | } 67 | 68 | hasBundleConfigFile(): boolean 69 | { 70 | return fs.existsSync(this.getBundleConfigFilePath()); 71 | } 72 | 73 | getScriptEs6FilePath(): string 74 | { 75 | return path.join(this.getPath(), 'script.es6.js'); 76 | } 77 | 78 | hasScriptEs6FilePath(): boolean 79 | { 80 | return fs.existsSync(this.getScriptEs6FilePath()); 81 | } 82 | 83 | getPhpConfigFilePath(): string 84 | { 85 | return path.join(this.getPath(), 'config.php'); 86 | } 87 | 88 | hasPhpConfigFile(): boolean 89 | { 90 | return fs.existsSync(this.getPhpConfigFilePath()); 91 | } 92 | 93 | getPhpConfig(): any 94 | { 95 | return this.#cache.remember('phpConfig', () => { 96 | const config = new PhpConfigManager(); 97 | if (this.hasPhpConfigFile()) 98 | { 99 | config.loadFromFile(this.getPhpConfigFilePath()); 100 | } 101 | 102 | return config; 103 | }); 104 | } 105 | 106 | getBaseTSConfig(): string 107 | { 108 | return path.join(this.getPath(), 'tsconfig.base.json'); 109 | } 110 | 111 | hasBaseTSConfig(): boolean 112 | { 113 | return fs.existsSync(this.getBaseTSConfig()); 114 | } 115 | 116 | getTSConfigPath(): string 117 | { 118 | return path.join(this.getPath(), 'tsconfig.json'); 119 | } 120 | 121 | hasTSConfig(): boolean 122 | { 123 | return fs.existsSync(this.getTSConfigPath()); 124 | } 125 | 126 | generateBaseTSConfig(dependencies: Array): FlexibleCompilerOptions 127 | { 128 | return { 129 | compilerOptions: { 130 | paths: dependencies.reduce((acc, extensionName) => { 131 | const extension = PackageResolver.resolve(extensionName); 132 | const relativeInputPath = path.relative(this.getPath(), extension.getInputPath()); 133 | 134 | acc[extensionName] = [relativeInputPath]; 135 | 136 | return acc; 137 | }, {}), 138 | }, 139 | }; 140 | } 141 | 142 | generateTSConfig(): FlexibleCompilerOptions 143 | { 144 | return { 145 | extends: './tsconfig.base.json', 146 | }; 147 | } 148 | 149 | generateTSConfigs() 150 | { 151 | if (this.getInputPath().endsWith('.ts')) 152 | { 153 | if (!this.hasTSConfig()) 154 | { 155 | const tsConfig = this.generateTSConfig(); 156 | const tsConfigContents = JSON.stringify(tsConfig, null, 4); 157 | 158 | fs.writeFileSync(this.getTSConfigPath(), tsConfigContents); 159 | } 160 | 161 | const dependencies = this.getExternalDependencies().map((dependencyNode) => { 162 | return dependencyNode.name; 163 | }); 164 | 165 | const baseTSConfig = this.generateBaseTSConfig(dependencies); 166 | const baseTSConfigContents = JSON.stringify(baseTSConfig, null, 4); 167 | 168 | fs.writeFileSync(this.getBaseTSConfig(), baseTSConfigContents, 'utf-8'); 169 | } 170 | } 171 | 172 | abstract getName(): string 173 | abstract getModuleName(): string 174 | 175 | getBundleConfig(): BundleConfigManager 176 | { 177 | return this.#cache.remember('bundleConfig', () => { 178 | const config = new BundleConfigManager(); 179 | if (this.hasBundleConfigFile()) 180 | { 181 | config.loadFromFile(this.getBundleConfigFilePath()); 182 | } 183 | else if (this.hasScriptEs6FilePath()) 184 | { 185 | config.set('input', 'script.es6.js'); 186 | config.set('output', { js: './script.js', css: './style.css' }); 187 | config.set('adjustConfigPhp', false); 188 | } 189 | 190 | return config; 191 | }); 192 | } 193 | 194 | addError(error: any) 195 | { 196 | this.#errors.add(error); 197 | } 198 | 199 | getErrors(): Array 200 | { 201 | return [...this.#errors]; 202 | } 203 | 204 | addWarning(warning: RollupLog) 205 | { 206 | this.#warnings.add(warning); 207 | } 208 | 209 | getWarnings(): Array 210 | { 211 | return [...this.#warnings]; 212 | } 213 | 214 | getWarningsSummary(): string 215 | { 216 | const counts = this.getWarnings().reduce((acc, warning) => { 217 | if (!Object.hasOwn(acc, warning.code)) 218 | { 219 | acc[warning.code] = 0; 220 | } 221 | 222 | acc[warning.code] += 1; 223 | 224 | return acc; 225 | }, {}); 226 | 227 | return Object.entries(counts).map(([key, count]) => { 228 | return `${key}: ${count}`; 229 | }).join('\n '); 230 | } 231 | 232 | addExternalDependency(dependency: DependencyNode) 233 | { 234 | const hasDependency = this.#externalDependencies.find((currentDependency: DependencyNode) => { 235 | return currentDependency.name === dependency.name 236 | }); 237 | 238 | if (!hasDependency) 239 | { 240 | this.#externalDependencies.push(dependency); 241 | } 242 | } 243 | 244 | getExternalDependencies(): Array 245 | { 246 | return this.#cache.remember('externalDependencies', () => { 247 | return [...this.#externalDependencies].sort((a: DependencyNode, b: DependencyNode) => { 248 | const prefixA = a.name.split('.')[0]; 249 | const prefixB = b.name.split('.')[0]; 250 | 251 | if (prefixA !== prefixB) 252 | { 253 | return prefixA.localeCompare(prefixB); 254 | } 255 | 256 | return a.name.localeCompare(b.name); 257 | }); 258 | }); 259 | } 260 | 261 | getTargets(): Array 262 | { 263 | const bundleConfig = this.getBundleConfig(); 264 | if (bundleConfig.get('browserslist') === true) 265 | { 266 | const targets = browserslist.loadConfig({ 267 | path: this.getPath(), 268 | }); 269 | 270 | if (targets.length > 0) 271 | { 272 | return targets; 273 | } 274 | } 275 | 276 | return bundleConfig.get('browserslist'); 277 | } 278 | 279 | getGlobal(): { [name: string]: string } 280 | { 281 | const name = this.getName(); 282 | const namespace = this.getBundleConfig().get('namespace'); 283 | 284 | return { [name]: namespace }; 285 | } 286 | 287 | getGlobals(): Record 288 | { 289 | return this.getExternalDependencies().reduce((acc, dependency) => { 290 | const extension = PackageResolver.resolve(dependency.name); 291 | if (extension) 292 | { 293 | return { ...acc, ...extension.getGlobal() }; 294 | } 295 | 296 | return acc; 297 | }, {}); 298 | } 299 | 300 | getInputPath(): string 301 | { 302 | return path.join(this.getPath(), this.getBundleConfig().get('input')); 303 | } 304 | 305 | getOutputJsPath(): string 306 | { 307 | return path.join(this.getPath(), this.getBundleConfig().get('output').js); 308 | } 309 | 310 | getOutputCssPath(): string 311 | { 312 | return path.join(this.getPath(), this.getBundleConfig().get('output').css); 313 | } 314 | 315 | getSourceDirectoryPath(): string 316 | { 317 | return path.join(this.getPath(), 'src'); 318 | } 319 | 320 | getSourceFiles(): Array 321 | { 322 | return this.#cache.remember('sourceFiles', () => { 323 | return fg.sync( 324 | BasePackage.SOURCE_FILES_PATTERN, 325 | { 326 | cwd: this.getSourceDirectoryPath(), 327 | dot: true, 328 | onlyFiles: true, 329 | unique: true, 330 | absolute: true, 331 | }, 332 | ); 333 | }); 334 | } 335 | 336 | getJavaScriptSourceFiles(): Array 337 | { 338 | return this.#cache.remember('javaScriptSourceFiles', () => { 339 | return this.getSourceFiles().filter((sourceFile) => { 340 | return sourceFile.endsWith(`.${BasePackage.JAVASCRIPT_EXTENSION}`); 341 | }); 342 | }); 343 | } 344 | 345 | removeJavaScriptSourceFiles() 346 | { 347 | this.getJavaScriptSourceFiles().forEach((sourceFile) => { 348 | if (fs.existsSync(sourceFile)) 349 | { 350 | fs.unlinkSync(sourceFile); 351 | } 352 | }); 353 | 354 | this.#cache.forget('sourceFiles'); 355 | this.#cache.forget('javaScriptSourceFiles'); 356 | } 357 | 358 | getTypeScriptSourceFiles(): Array 359 | { 360 | return this.#cache.remember('typeScriptSourceFiles', () => { 361 | return this.getSourceFiles().filter((sourceFile) => { 362 | return sourceFile.endsWith(`.${BasePackage.TYPESCRIPT_EXTENSION}`); 363 | }); 364 | }); 365 | } 366 | 367 | removeTypeScriptSourceFiles() 368 | { 369 | this.getTypeScriptSourceFiles().forEach((sourceFile) => { 370 | if (fs.existsSync(sourceFile)) 371 | { 372 | fs.unlinkSync(sourceFile); 373 | } 374 | }); 375 | 376 | this.#cache.forget('sourceFiles'); 377 | this.#cache.forget('typeScriptSourceFiles'); 378 | } 379 | 380 | getActualSourceFiles(): Array 381 | { 382 | if (this.isTypeScriptMode()) 383 | { 384 | return this.getTypeScriptSourceFiles(); 385 | } 386 | 387 | return this.getJavaScriptSourceFiles(); 388 | } 389 | 390 | isTypeScriptMode(): boolean 391 | { 392 | return this.getInputPath().endsWith('.ts'); 393 | } 394 | 395 | getUnitTestsDirectoryPath(): string 396 | { 397 | return path.join(this.getPath(), 'test'); 398 | } 399 | 400 | getEndToEndTestsDirectoryPath(): string 401 | { 402 | return path.join(this.getPath(), 'test', 'e2e'); 403 | } 404 | 405 | #onWarnHandler(warning: RollupLog, warn: LoggingFunction) 406 | { 407 | if (warning.code === 'UNRESOLVED_IMPORT' && isExternalDependencyName(warning.exporter)) 408 | { 409 | this.addExternalDependency({ 410 | name: warning.exporter, 411 | }); 412 | 413 | return; 414 | } 415 | 416 | this.addWarning(warning); 417 | } 418 | 419 | #getRollupInputOptions(): InputOptions 420 | { 421 | return { 422 | input: this.getInputPath(), 423 | plugins: [ 424 | ...(() => { 425 | if (this.getInputPath().endsWith('.ts')) 426 | { 427 | const tsconfig = JSON.parse( 428 | fs.readFileSync( 429 | path.join(Environment.getRoot(), 'tsconfig.json'), 430 | 'utf8', 431 | ), 432 | ); 433 | 434 | return [ 435 | typescript({ 436 | tsconfig: false, 437 | compilerOptions: { 438 | target: 'ESNext', 439 | noEmitOnError: true, 440 | strict: true, 441 | paths: tsconfig.compilerOptions.paths, 442 | }, 443 | }), 444 | ]; 445 | } 446 | 447 | return []; 448 | })(), 449 | nodeResolve({ 450 | browser: true, 451 | }), 452 | babel({ 453 | babelHelpers: 'external', 454 | presets: [ 455 | [ 456 | presetEnv, 457 | { 458 | targets: this.getTargets(), 459 | modules: false, 460 | }, 461 | ], 462 | ], 463 | plugins: [ 464 | flowStripTypesPlugin, 465 | externalHelpersPlugin, 466 | ], 467 | }), 468 | commonjs(), 469 | postcss({ 470 | extract: this.getOutputCssPath(), 471 | sourceMap: false, 472 | plugins: [ 473 | 474 | ] 475 | }), 476 | jsonPlugin(), 477 | imagePlugin(), 478 | ], 479 | onwarn: this.#onWarnHandler.bind(this), 480 | treeshake: { 481 | moduleSideEffects: false, 482 | propertyReadSideEffects: false, 483 | tryCatchDeoptimization: false, 484 | }, 485 | }; 486 | } 487 | 488 | #getRollupOutputOptions(): OutputOptions 489 | { 490 | return { 491 | file: this.getOutputJsPath(), 492 | name: this.getBundleConfig().get('namespace'), 493 | format: 'iife', 494 | banner: '/* eslint-disable */', 495 | extend: true, 496 | globals: { 497 | ...this.getGlobals(), 498 | }, 499 | }; 500 | } 501 | 502 | async build(): Promise<{ 503 | warnings: Array, 504 | errors: Array, 505 | warningsSummary: string, 506 | bundles: Array<{ 507 | fileName: string, 508 | size: number, 509 | type: 'chunk' | 'asset'; 510 | }>, 511 | externalDependenciesCount: number, 512 | }> 513 | { 514 | const bundle = await rollup( 515 | this.#getRollupInputOptions(), 516 | ); 517 | 518 | const result = await bundle.write( 519 | this.#getRollupOutputOptions(), 520 | ); 521 | 522 | await bundle.close(); 523 | 524 | const warnings = this.getWarnings(); 525 | const warningsSummary = this.getWarningsSummary(); 526 | const errors = this.getErrors(); 527 | const externalDependenciesCount = this.getExternalDependencies().length; 528 | const bundles = result.output.map((chunk) => { 529 | const size = 530 | chunk.type === 'asset' 531 | ? Buffer.byteLength(chunk.source, 'utf8') 532 | : Buffer.byteLength(chunk.code, 'utf8'); 533 | 534 | return { 535 | fileName: chunk.fileName, 536 | size, 537 | type: chunk.type 538 | }; 539 | }); 540 | 541 | const rel = this.getExternalDependencies().map((dependency: DependencyNode) => { 542 | return dependency.name; 543 | }); 544 | 545 | this.getPhpConfig().set('rel', rel); 546 | this.getPhpConfig().save(this.getPhpConfigFilePath()); 547 | 548 | this.#externalDependencies.splice(0, this.#externalDependencies.length); 549 | this.#warnings.clear(); 550 | this.#errors.clear(); 551 | 552 | return { 553 | warnings, 554 | warningsSummary, 555 | errors, 556 | bundles, 557 | externalDependenciesCount, 558 | }; 559 | } 560 | 561 | async lint(): Promise 562 | { 563 | const eslint = new ESLint({ 564 | errorOnUnmatchedPattern: false, 565 | cwd: Environment.getRoot(), 566 | }); 567 | 568 | const results = await eslint.lintFiles( 569 | path.join(this.getPath(), 'src', '**/*.js'), 570 | ); 571 | 572 | return new LintResult({ 573 | results, 574 | }); 575 | } 576 | 577 | async getDependencies(): Promise> 578 | { 579 | return this.#cache.remember('dependencies', async () => { 580 | const phpConfig = this.getPhpConfig(); 581 | if (phpConfig) 582 | { 583 | const rel = phpConfig.get('rel'); 584 | if (Array.isArray(rel)) 585 | { 586 | return rel.map((name: string) => { 587 | return { name }; 588 | }); 589 | } 590 | } 591 | 592 | void await rollup( 593 | this.#getRollupInputOptions(), 594 | ); 595 | 596 | return this.getExternalDependencies(); 597 | }); 598 | } 599 | 600 | async getDependenciesTree(options: { size?: boolean, unique?: boolean } = {}): Promise> 601 | { 602 | return this.#cache.remember(`dependenciesTree+${options.size}+${options.unique}`, () => { 603 | return buildDependenciesTree({ 604 | target: this, 605 | ...options, 606 | }); 607 | }); 608 | } 609 | 610 | async getFlattedDependenciesTree(unique: boolean = true): Promise> 611 | { 612 | return this.#cache.remember(`flattedDependenciesTree+${unique}`, async () => { 613 | return flattenTree(await this.getDependenciesTree(), unique); 614 | }); 615 | } 616 | 617 | normalizePath(sourcePath: string): string 618 | { 619 | if (sourcePath.startsWith('/')) 620 | { 621 | const nameSegment = `${this.getName().split('.').join('/')}/`; 622 | const [, relativePath] = sourcePath.split(nameSegment); 623 | 624 | return relativePath; 625 | } 626 | 627 | return sourcePath; 628 | } 629 | 630 | getBundlesSize(): { css: number, js: number } 631 | { 632 | return this.#cache.remember('bundleSize', () => { 633 | let result = { css: 0, js: 0 }; 634 | const isExistJsBundle = fs.existsSync(this.getOutputJsPath()); 635 | const isExistCssBundle = fs.existsSync(this.getOutputCssPath()); 636 | if (isExistJsBundle || isExistCssBundle) 637 | { 638 | if (fs.existsSync(this.getOutputJsPath())) 639 | { 640 | result.js = fs.statSync(this.getOutputJsPath()).size; 641 | } 642 | 643 | if (fs.existsSync(this.getOutputCssPath())) 644 | { 645 | result.css = fs.statSync(this.getOutputCssPath()).size; 646 | } 647 | } 648 | else 649 | { 650 | const phpConfig = this.getPhpConfig(); 651 | const jsFiles = [phpConfig.get('js')].flat(2); 652 | const cssFiles = [phpConfig.get('css')].flat(2); 653 | 654 | result.js = jsFiles.reduce((acc, filePath) => { 655 | if (filePath.length > 0) 656 | { 657 | const normalizedPath = this.normalizePath(filePath); 658 | const fullPath = path.join(this.getPath(), normalizedPath); 659 | if (fs.existsSync(fullPath)) 660 | { 661 | acc += fs.statSync(fullPath).size; 662 | } 663 | } 664 | 665 | return acc; 666 | }, 0); 667 | 668 | result.css = cssFiles.reduce((acc, filePath) => { 669 | if (filePath.length > 0) 670 | { 671 | const normalizedPath = this.normalizePath(filePath); 672 | const fullPath = path.join(this.getPath(), normalizedPath); 673 | if (fs.existsSync(fullPath)) 674 | { 675 | acc += fs.statSync(fullPath).size; 676 | } 677 | } 678 | 679 | return acc; 680 | }, 0); 681 | } 682 | 683 | return result; 684 | }); 685 | } 686 | 687 | async getDependenciesSize(): Promise<{ js: number, css: number }> 688 | { 689 | return this.#cache.remember('getDependenciesSize', async () => { 690 | const dependencies = await this.getFlattedDependenciesTree(); 691 | 692 | return dependencies.reduce((acc, dependency: DependencyNode) => { 693 | const extension = PackageResolver.resolve(dependency.name); 694 | if (extension) 695 | { 696 | const { js, css } = extension.getBundlesSize(); 697 | acc.js += js; 698 | acc.css += css; 699 | } 700 | 701 | return acc; 702 | 703 | }, { js: 0, css: 0 }); 704 | }); 705 | } 706 | 707 | async getTotalTransferredSize(): Promise<{ css: number, js: number }> 708 | { 709 | const bundlesSize = this.getBundlesSize(); 710 | const dependenciesSize = await this.getDependenciesSize(); 711 | 712 | return { 713 | js: bundlesSize.js + dependenciesSize.js, 714 | css: bundlesSize.css + dependenciesSize.css, 715 | }; 716 | } 717 | 718 | async getUnitTests(): Promise> 719 | { 720 | const patterns = [ 721 | '**/*.test.js', 722 | '!**/e2e' 723 | ]; 724 | 725 | return fg.async( 726 | patterns, 727 | { 728 | cwd: this.getUnitTestsDirectoryPath(), 729 | dot: true, 730 | onlyFiles: true, 731 | unique: true, 732 | absolute: true, 733 | }, 734 | ); 735 | } 736 | 737 | async getUnitTestsBundle(): Promise 738 | { 739 | const sourceTestsCode = (await this.getUnitTests()) 740 | .map((filePath) => { 741 | return `import './${path.relative(this.getPath(), filePath)}';`; 742 | }) 743 | .join('\n'); 744 | 745 | const dependencies = []; 746 | const rollupInputOptions = this.#getRollupInputOptions(); 747 | const entries = { 748 | 'sourceCode.js': sourceTestsCode, 749 | }; 750 | const bundle = await rollup({ 751 | ...rollupInputOptions, 752 | input: 'sourceCode.js', 753 | plugins: [ 754 | { 755 | name: 'virtual-module-plugin', 756 | resolveId(id) { 757 | if (id in entries) 758 | { 759 | return id; 760 | } 761 | 762 | return null; 763 | }, 764 | load(id) { 765 | if (id in entries) 766 | { 767 | return entries[id]; 768 | } 769 | 770 | return null; 771 | }, 772 | }, 773 | ...(() => { 774 | if (Array.isArray(rollupInputOptions.plugins)) 775 | { 776 | return rollupInputOptions.plugins; 777 | } 778 | 779 | return []; 780 | })(), 781 | ], 782 | onwarn: (warning, warn) => { 783 | if (warning.code === 'UNRESOLVED_IMPORT' && isExternalDependencyName(warning.exporter)) 784 | { 785 | dependencies.push(warning.exporter); 786 | 787 | return; 788 | } 789 | 790 | warn(warning); 791 | }, 792 | treeshake: false, 793 | }); 794 | 795 | const globals = dependencies.reduce((acc, dependency) => { 796 | const extension = PackageResolver.resolve(dependency); 797 | if (extension) 798 | { 799 | return { ...acc, ...extension.getGlobal() }; 800 | } 801 | 802 | return acc; 803 | }, {}); 804 | 805 | const result = await bundle.generate({ 806 | file: path.join(this.getPath(), 'test.bundle.js'), 807 | format: 'iife', 808 | banner: '/* eslint-disable */', 809 | extend: true, 810 | globals: { 811 | ...globals, 812 | }, 813 | }); 814 | 815 | await bundle.close(); 816 | 817 | const outputEntry = result.output.at(0) as OutputChunk; 818 | 819 | return outputEntry?.code; 820 | } 821 | 822 | async getEndToEndTests(): Promise> 823 | { 824 | const patterns = [ 825 | '**/*.test.js', 826 | '**/*.spec.js', 827 | ]; 828 | 829 | return fg.async( 830 | patterns, 831 | { 832 | cwd: this.getEndToEndTestsDirectoryPath(), 833 | dot: true, 834 | onlyFiles: true, 835 | unique: true, 836 | absolute: true, 837 | }, 838 | ); 839 | } 840 | 841 | async runUnitTests(args: Record): Promise { 842 | const browser = await playwright.chromium.launch({ 843 | headless: args.headed !== true, 844 | }); 845 | const context = await browser.newContext(); 846 | const page = await context.newPage(); 847 | 848 | try 849 | { 850 | await page.goto(`https://bitrix24.io/dev/ui/cli/mocha-wrapper.php?extension=${this.getName()}`); 851 | 852 | const testsCodeBundle = await this.getUnitTestsBundle(); 853 | 854 | const report = []; 855 | page.on('console', async (message) => { 856 | const values = []; 857 | for (const arg of message.args()) 858 | { 859 | values.push(await arg.jsonValue()); 860 | } 861 | 862 | const [key, value] = values; 863 | if (key === 'unit_report_token') 864 | { 865 | try 866 | { 867 | report.push(JSON.parse(value)); 868 | } 869 | catch (error) 870 | { 871 | console.error(error); 872 | } 873 | } 874 | }); 875 | 876 | await page.evaluate(() => { 877 | // @ts-ignore 878 | globalThis.mocha.setup({ 879 | ui: 'bdd', 880 | // @ts-ignore 881 | reporter: ProxyReporter, 882 | checkLeaks: true, 883 | timeout: 10000, 884 | inlineDiffs: true, 885 | color: true, 886 | }); 887 | }); 888 | 889 | await page.addScriptTag({ 890 | content: testsCodeBundle, 891 | }); 892 | 893 | type TestStats = Promise<{ stats: any }>; 894 | 895 | const { stats } = await page.evaluate((): TestStats => { 896 | return new Promise((resolve) => { 897 | // @ts-ignore 898 | globalThis.mocha.run(() => { 899 | resolve({ 900 | // @ts-ignore 901 | stats: globalThis.mocha.stats, 902 | }); 903 | }); 904 | }); 905 | }); 906 | 907 | 908 | return { 909 | report, 910 | stats, 911 | }; 912 | } 913 | catch (error) 914 | { 915 | console.error('Error during test execution:', error); 916 | throw error; 917 | } 918 | } 919 | 920 | async runEndToEndTests(sourceArgs: Record): Promise 921 | { 922 | const tests = await this.getEndToEndTests(); 923 | if (tests.length === 0) 924 | { 925 | return Promise.resolve({ 926 | status: 'NO_TESTS_FOUND', 927 | code: 1, 928 | }); 929 | } 930 | 931 | const args = ['playwright', 'test', ...tests]; 932 | 933 | if (Object.hasOwn(sourceArgs, 'headed')) 934 | { 935 | args.push('--headed'); 936 | } 937 | 938 | if (Object.hasOwn(sourceArgs, 'debug')) 939 | { 940 | args.push('--debug'); 941 | } 942 | 943 | if (Object.hasOwn(sourceArgs, 'grep')) 944 | { 945 | args.push('--grep'); 946 | } 947 | 948 | const process = spawn('npx', args, { 949 | stdio: 'inherit', 950 | cwd: global.process.cwd(), 951 | }); 952 | 953 | return new Promise((resolve, reject) => { 954 | process.on('close', (code) => { 955 | if (code === 0) 956 | { 957 | resolve({ 958 | status: 'TESTS_PASSED', 959 | code: 0, 960 | }); 961 | } 962 | else 963 | { 964 | reject({ 965 | status: 'TESTS_FAILED', 966 | code: 0, 967 | }); 968 | } 969 | }); 970 | }); 971 | } 972 | } 973 | 974 | --------------------------------------------------------------------------------