From 20bfbadd245de9f89d56a10fb274019a50228812 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 24 Sep 2024 10:55:46 +0200 Subject: [PATCH] feat(workspace-plugin): implement build executor (#32439) --- .gitignore | 2 + tools/workspace-plugin/jest.config.ts | 10 +- .../assets/libs/proj/hello.md__tmpl__ | 0 .../__fixtures__/assets/libs/proj/hello.txt | 0 .../__fixtures__/assets/libs/proj/world.md | 0 .../executor/libs/proj/.babelrc.json | 14 ++ .../__fixtures__/executor/libs/proj/.swcrc | 30 +++ .../__fixtures__/executor/libs/proj/spec.md | 0 .../executor/libs/proj/src/greeter.spec.ts | 7 + .../executor/libs/proj/src/greeter.styles.ts | 5 + .../executor/libs/proj/src/greeter.ts | 12 + .../executor/libs/proj/src/index.ts | 1 + .../src/executors/build/executor.spec.ts | 213 +++++++++++++++++- .../src/executors/build/executor.ts | 51 ++++- .../src/executors/build/lib/assets.spec.ts | 83 +++++++ .../src/executors/build/lib/assets.ts | 79 +++++++ .../src/executors/build/lib/babel.ts | 125 ++++++++++ .../src/executors/build/lib/clean.ts | 25 ++ .../src/executors/build/lib/shared.ts | 39 ++++ .../src/executors/build/lib/swc.ts | 66 ++++++ .../src/executors/build/schema.d.ts | 70 +++++- .../src/executors/build/schema.json | 64 +++++- 22 files changed, 878 insertions(+), 18 deletions(-) create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.md__tmpl__ create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.txt create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/world.md create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.babelrc.json create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.swcrc create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/spec.md create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.spec.ts create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.styles.ts create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.ts create mode 100644 tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/index.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/assets.spec.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/assets.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/babel.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/clean.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/shared.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/swc.ts diff --git a/.gitignore b/.gitignore index f2491567f66dd0..b2ad8eaea6fc5b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ dist dist-storybook screenshots !tools/**/lib +tools/**/__fixtures__/**/lib +tools/**/__fixtures__/**/lib-commonjs *.tar.gz diff --git a/tools/workspace-plugin/jest.config.ts b/tools/workspace-plugin/jest.config.ts index 1e7ea9ee974a7f..9fc6c42f824ef3 100644 --- a/tools/workspace-plugin/jest.config.ts +++ b/tools/workspace-plugin/jest.config.ts @@ -4,9 +4,15 @@ export default { displayName: 'workspace-plugin', preset: '../../jest.preset.js', transform: { - '^.+\\.[tj]s$': ['@swc/jest', {}], + '^.+\\.[tj]s$': ['@swc/jest', { exclude: '/node_modules/' }], }, - moduleFileExtensions: ['ts', 'js', 'html'], + /** + * NOTE: because @swc/types ships index.ts and has not properly set up the package.json "types" field, if we set 'ts' first, it will try to use the index.ts file and fail. + */ + moduleFileExtensions: ['js', 'ts', 'html'], + testPathIgnorePatterns: ['/node_modules/', '/__fixtures__/'], + transformIgnorePatterns: ['/node_modules/', '/__fixtures__/'], + watchPathIgnorePatterns: ['/node_modules/', '/__fixtures__/'], coverageDirectory: '../../coverage/tools/workspace-plugin', setupFiles: ['/jest-setup.js'], } as import('@jest/types').Config.InitialOptions; diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.md__tmpl__ b/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.md__tmpl__ new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.txt b/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/hello.txt new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/world.md b/tools/workspace-plugin/src/executors/build/__fixtures__/assets/libs/proj/world.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.babelrc.json b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.babelrc.json new file mode 100644 index 00000000000000..f609f2ec9a5e80 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.babelrc.json @@ -0,0 +1,14 @@ +{ + "presets": [ + [ + "@griffel", + { + "modules": [ + { "moduleSource": "@griffel/core", "importName": "makeStyles" }, + { "moduleSource": "@griffel/react", "importName": "makeStyles" } + ] + } + ] + ], + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.swcrc b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.swcrc new file mode 100644 index 00000000000000..b4ffa86dee3067 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/.swcrc @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/spec.md b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/spec.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.spec.ts b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.spec.ts new file mode 100644 index 00000000000000..a3ed95e3204ad1 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.spec.ts @@ -0,0 +1,7 @@ +import { greeter } from './greeter'; + +describe('greeter', () => { + it('should work', () => { + expect(greeter('Hello', { name: 'Mr Wick' })).toContain('Hello'); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.styles.ts b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.styles.ts new file mode 100644 index 00000000000000..53344591d33064 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.styles.ts @@ -0,0 +1,5 @@ +import { makeStyles } from '@griffel/react'; + +export const useStyles = makeStyles({ + root: { color: 'red' }, +}); diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.ts b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.ts new file mode 100644 index 00000000000000..27d654e412d0cd --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/greeter.ts @@ -0,0 +1,12 @@ +import { useStyles } from './greeter.styles'; +export function greeter(greeting: string, user: User): string { + const styles = useStyles(); + return `

${greeting} ${user.name} from ${user.hometown?.name}

`; +} + +type User = { + name: string; + hometown?: { + name: string; + }; +}; diff --git a/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/index.ts b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/index.ts new file mode 100644 index 00000000000000..f1f87dc2526fe8 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/__fixtures__/executor/libs/proj/src/index.ts @@ -0,0 +1 @@ +export { greeter } from './greeter'; diff --git a/tools/workspace-plugin/src/executors/build/executor.spec.ts b/tools/workspace-plugin/src/executors/build/executor.spec.ts index e194239ca11f93..9d7bec167a544f 100644 --- a/tools/workspace-plugin/src/executors/build/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/build/executor.spec.ts @@ -1,18 +1,223 @@ -import { ExecutorContext } from '@nx/devkit'; +import { type ExecutorContext, logger, stripIndents } from '@nx/devkit'; import { BuildExecutorSchema } from './schema'; import executor from './executor'; +import { join } from 'node:path'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; + +// ===== mocks start ===== +import { rm } from 'node:fs/promises'; +import { measureStart, measureEnd } from '../../utils'; +// ===== mocks end ===== + +const options: BuildExecutorSchema = { + sourceRoot: 'src', + outputPathRoot: 'libs/proj/dist', + moduleOutput: [ + { + module: 'commonjs', + outputPath: 'lib-commonjs', + }, + { + module: 'es6', + outputPath: 'lib', + }, + ], + assets: [ + { + input: 'libs/proj/', + output: 'assets/', + glob: '*.md', + }, + ], + clean: true, +}; + +const workspaceRoot = join(__dirname, '__fixtures__/executor'); -const options: BuildExecutorSchema = {}; const context: ExecutorContext = { - root: '', + root: workspaceRoot, cwd: process.cwd(), isVerbose: false, + projectName: 'proj', + projectsConfigurations: { + version: 2, + projects: { + proj: { + root: 'libs/proj', + name: 'proj', + }, + }, + }, }; +jest.mock('node:fs/promises', () => { + const actualFs = jest.requireActual('node:fs/promises'); + + return { + ...actualFs, + rm: jest.fn(actualFs.rm), + }; +}); + +jest.mock('../../utils', () => { + return { + measureStart: jest.fn(), + measureEnd: jest.fn(), + }; +}); + +const rmMock = rm as jest.Mock; +const measureStartMock = measureStart as jest.Mock; +const measureEndMock = measureEnd as jest.Mock; + describe('Build Executor', () => { it('can run', async () => { + const loggerLogSpy = jest.spyOn(logger, 'log').mockImplementation(() => { + return; + }); + const loggerVerboseSpy = jest.spyOn(logger, 'verbose').mockImplementation(() => { + return; + }); + const output = await executor(options, context); expect(output.success).toBe(true); - }); + + const loggerLogSpyCalls = loggerLogSpy.mock.calls.flat(); + const [clearLogs, ...restOfLogs] = loggerLogSpyCalls; + + expect(stripIndents`${clearLogs}`).toEqual(stripIndents` + Cleaning outputs: + + - ${workspaceRoot}/libs/proj/lib-commonjs + - ${workspaceRoot}/libs/proj/lib + - ${workspaceRoot}/libs/proj/dist/assets/spec.md + `); + + expect(restOfLogs).toEqual([ + 'Compiling with SWC for module:es6...', + 'Processing griffel AOT with babel: 1 files', + 'Compiling with SWC for module:commonjs...', + ]); + + expect(loggerVerboseSpy.mock.calls.flat()).toEqual([ + `babel: transformed ${workspaceRoot}/libs/proj/lib/greeter.styles.js`, + ]); + + expect(rmMock.mock.calls.flat()).toEqual([ + `${workspaceRoot}/libs/proj/lib-commonjs`, + { + force: true, + recursive: true, + }, + `${workspaceRoot}/libs/proj/lib`, + { + force: true, + recursive: true, + }, + `${workspaceRoot}/libs/proj/dist/assets/spec.md`, + { + force: true, + recursive: true, + }, + ]); + + expect(measureStartMock).toHaveBeenCalledTimes(1); + expect(measureEndMock).toHaveBeenCalledTimes(1); + + // ===================== + // assert build Assets + // ===================== + expect(existsSync(join(workspaceRoot, 'libs/proj/dist/assets', 'spec.md'))).toBe(true); + expect(readdirSync(join(workspaceRoot, 'libs/proj/lib'))).toEqual([ + 'greeter.js', + 'greeter.js.map', + 'greeter.styles.js', + 'greeter.styles.js.map', + 'index.js', + 'index.js.map', + ]); + expect(readdirSync(join(workspaceRoot, 'libs/proj/lib-commonjs'))).toEqual([ + 'greeter.js', + 'greeter.js.map', + 'greeter.styles.js', + 'greeter.styles.js.map', + 'index.js', + 'index.js.map', + ]); + + // ==================================== + // assert swc output based on settings + // ==================================== + expect(readFileSync(join(workspaceRoot, 'libs/proj/lib/greeter.js'), 'utf-8')).toMatchInlineSnapshot(` + "import { useStyles } from './greeter.styles'; + export function greeter(greeting, user) { + var _user_hometown; + const styles = useStyles(); + return \`

\${greeting} \${user.name} from \${(_user_hometown = user.hometown) === null || _user_hometown === void 0 ? void 0 : _user_hometown.name}

\`; + } + " + `); + expect(readFileSync(join(workspaceRoot, 'libs/proj/lib/greeter.js.map'), 'utf-8')).toMatchInlineSnapshot( + `"{\\"version\\":3,\\"sources\\":[\\"greeter.ts\\"],\\"sourcesContent\\":[\\"import { useStyles } from './greeter.styles';\\\\nexport function greeter(greeting: string, user: User): string {\\\\n const styles = useStyles();\\\\n return \`

\${greeting} \${user.name} from \${user.hometown?.name}

\`;\\\\n}\\\\n\\\\ntype User = {\\\\n name: string;\\\\n hometown?: {\\\\n name: string;\\\\n };\\\\n};\\\\n\\"],\\"names\\":[\\"useStyles\\",\\"greeter\\",\\"greeting\\",\\"user\\",\\"styles\\",\\"name\\",\\"hometown\\"],\\"rangeMappings\\":\\";;;;;\\",\\"mappings\\":\\"AAAA,SAASA,SAAS,QAAQ,mBAAmB;AAC7C,OAAO,SAASC,QAAQC,QAAgB,EAAEC,IAAU;QAEYA;IAD9D,MAAMC,SAASJ;IACf,OAAO,CAAC,WAAW,EAAEI,OAAO,EAAE,EAAEF,SAAS,CAAC,EAAEC,KAAKE,IAAI,CAAC,MAAM,GAAEF,iBAAAA,KAAKG,QAAQ,cAAbH,qCAAAA,eAAeE,IAAI,CAAC,KAAK,CAAC;AAC1F\\"}"`, + ); + + expect(readFileSync(join(workspaceRoot, 'libs/proj/lib-commonjs/greeter.js'), 'utf-8')).toMatchInlineSnapshot(` + "\\"use strict\\"; + Object.defineProperty(exports, \\"__esModule\\", { + value: true + }); + Object.defineProperty(exports, \\"greeter\\", { + enumerable: true, + get: function() { + return greeter; + } + }); + const _greeterstyles = require(\\"./greeter.styles\\"); + function greeter(greeting, user) { + var _user_hometown; + const styles = (0, _greeterstyles.useStyles)(); + return \`

\${greeting} \${user.name} from \${(_user_hometown = user.hometown) === null || _user_hometown === void 0 ? void 0 : _user_hometown.name}

\`; + } + " + `); + + // ===================== + // assert griffel AOT + // ===================== + expect(readFileSync(join(workspaceRoot, 'libs/proj/lib/greeter.styles.js'), 'utf-8')).toMatchInlineSnapshot(` + "import { __styles } from '@griffel/react'; + export const useStyles = /*#__PURE__*/__styles({ + root: { + sj55zd: \\"fe3e8s9\\" + } + }, { + d: [\\".fe3e8s9{color:red;}\\"] + });" + `); + expect(readFileSync(join(workspaceRoot, 'libs/proj/lib-commonjs/greeter.styles.js'), 'utf-8')) + .toMatchInlineSnapshot(` + "\\"use strict\\"; + Object.defineProperty(exports, \\"__esModule\\", { + value: true + }); + Object.defineProperty(exports, \\"useStyles\\", { + enumerable: true, + get: function() { + return useStyles; + } + }); + const _react = require(\\"@griffel/react\\"); + const useStyles = /*#__PURE__*/ (0, _react.__styles)({ + root: { + sj55zd: \\"fe3e8s9\\" + } + }, { + d: [ + \\".fe3e8s9{color:red;}\\" + ] + }); + " + `); + }, 30000); }); diff --git a/tools/workspace-plugin/src/executors/build/executor.ts b/tools/workspace-plugin/src/executors/build/executor.ts index c4aa127af344f2..3d67df9bba4854 100644 --- a/tools/workspace-plugin/src/executors/build/executor.ts +++ b/tools/workspace-plugin/src/executors/build/executor.ts @@ -1,11 +1,46 @@ -import { PromiseExecutor } from '@nx/devkit'; -import { BuildExecutorSchema } from './schema'; - -const runExecutor: PromiseExecutor = async options => { - console.log('Executor ran for Build', options); - return { - success: true, - }; +import { type ExecutorContext, type PromiseExecutor } from '@nx/devkit'; + +import { compileSwc } from './lib/swc'; +import { compileWithGriffelStylesAOT, hasStylesFilesToProcess } from './lib/babel'; +import { assetGlobsToFiles, copyAssets } from './lib/assets'; +import { cleanOutput } from './lib/clean'; +import { NormalizedOptions, normalizeOptions, processAsyncQueue } from './lib/shared'; + +import { measureEnd, measureStart } from '../../utils'; + +import { type BuildExecutorSchema } from './schema'; + +const runExecutor: PromiseExecutor = async (schema, context) => { + measureStart('BuildExecutor'); + + const options = normalizeOptions(schema, context); + + const success = await runBuild(options, context); + + measureEnd('BuildExecutor'); + + return { success }; }; export default runExecutor; + +// =========== + +async function runBuild(options: NormalizedOptions, context: ExecutorContext): Promise { + const assetFiles = assetGlobsToFiles(options.assets ?? [], context.root, options.outputPathRoot); + + const cleanResult = await cleanOutput(options, assetFiles); + if (!cleanResult) { + return false; + } + + if (hasStylesFilesToProcess(options)) { + return compileWithGriffelStylesAOT(options, () => copyAssets(assetFiles)); + } + + const compilationQueue = options.moduleOutput.map(outputConfig => { + return compileSwc(outputConfig, options); + }); + + return processAsyncQueue(compilationQueue, () => copyAssets(assetFiles)); +} diff --git a/tools/workspace-plugin/src/executors/build/lib/assets.spec.ts b/tools/workspace-plugin/src/executors/build/lib/assets.spec.ts new file mode 100644 index 00000000000000..f326448aae5fef --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/assets.spec.ts @@ -0,0 +1,83 @@ +import { join } from 'node:path'; + +// ==== mock start ==== +import { cp } from 'node:fs/promises'; +// ==== mock end ==== + +import { assetGlobsToFiles, copyAssets } from './assets'; + +jest.mock('node:fs/promises', () => { + return { + cp: jest.fn(async () => { + return; + }), + }; +}); +const cpMock = cp as jest.Mock; + +describe(`assets`, () => { + const rootDir = join(__dirname, '../__fixtures__/assets'); + it(`should copy assets in string or glob format`, async () => { + const actual = await copyAssets( + assetGlobsToFiles( + [ + 'libs/proj/world.md', + { + input: 'libs/proj', + output: 'copied-assets', + glob: '*.txt', + }, + ], + rootDir, + 'libs/proj/dist', + ), + ); + + expect(actual).toBe(true); + expect(cpMock.mock.calls.flat()).toEqual([ + // from + `${rootDir}/libs/proj/world.md`, + // to + `${rootDir}/libs/proj/dist/world.md`, + { + recursive: true, + }, + // from + `${rootDir}/libs/proj/hello.txt`, + // to + `${rootDir}/libs/proj/dist/copied-assets/hello.txt`, + { + recursive: true, + }, + ]); + }); + it(`should support substitutions`, async () => { + const actual = await copyAssets( + assetGlobsToFiles( + [ + { + input: 'libs/proj', + output: 'copied-assets', + glob: '*__tmpl__', + substitutions: { + __tmpl__: '', + }, + }, + ], + rootDir, + 'libs/proj/dist', + ), + ); + + expect(actual).toBe(true); + expect(cpMock.mock.calls.flat()).toEqual([ + // from + `${rootDir}/libs/proj/hello.md__tmpl__`, + // to + `${rootDir}/libs/proj/dist/copied-assets/hello.md`, + { + recursive: true, + }, + ]); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/lib/assets.ts b/tools/workspace-plugin/src/executors/build/lib/assets.ts new file mode 100644 index 00000000000000..a84f0b02c7d03a --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/assets.ts @@ -0,0 +1,79 @@ +import { globSync } from 'fast-glob'; +import { basename, join } from 'node:path'; +import { logger } from '@nx/devkit'; +import { cp } from 'node:fs/promises'; + +type FileInputOutput = { + input: string; + output: string; +}; +type AssetGlob = FileInputOutput & { + glob: string; + ignore?: string[]; + dot?: boolean; + substitutions?: Record; +}; +/** + * + * @param assets + * @param rootDir - workspace root (absolute path) + * @param outDir - output directory (relative path to rootDir) + * @param substitutions + */ +export function assetGlobsToFiles( + assets: Array, + rootDir: string, + outDir: string, +): FileInputOutput[] { + const files: FileInputOutput[] = []; + + const globbedFiles = (pattern: string, input = '', ignore: string[] = [], dot: boolean = false) => { + return globSync(pattern, { + cwd: input, + onlyFiles: true, + dot, + ignore, + }); + }; + + assets.forEach(asset => { + if (typeof asset === 'string') { + globbedFiles(asset, rootDir).forEach(globbedFile => { + files.push({ + input: join(rootDir, globbedFile), + output: join(rootDir, outDir, basename(globbedFile)), + }); + }); + return; + } + + globbedFiles(asset.glob, join(rootDir, asset.input), asset.ignore, asset.dot ?? false).forEach(globbedFile => { + const output = join(rootDir, outDir, asset.output, globbedFile); + const transformedOutput = Object.entries(asset.substitutions ?? {}).reduce((acc, [key, value]) => { + return acc.replace(key, value); + }, output); + + files.push({ + input: join(rootDir, asset.input, globbedFile), + output: transformedOutput, + }); + }); + }); + + return files; +} + +export async function copyAssets(files: FileInputOutput[]): Promise { + const copyAsyncQueue = files.map(file => { + return cp(file.input, file.output, { recursive: true }); + }); + + return Promise.all(copyAsyncQueue) + .then(() => { + return true; + }) + .catch(err => { + logger.error(err); + return false; + }); +} diff --git a/tools/workspace-plugin/src/executors/build/lib/babel.ts b/tools/workspace-plugin/src/executors/build/lib/babel.ts new file mode 100644 index 00000000000000..314a87ea01a512 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/babel.ts @@ -0,0 +1,125 @@ +/** + * + * TODO: remove this module and its usage once we will be able to remove griffel AOT from our build output -> https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/shared/build-system/stop-styles-transforms.md + */ + +import { writeFile, readFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import { type BabelFileResult, transformAsync } from '@babel/core'; +import { globSync } from 'fast-glob'; +import { logger } from '@nx/devkit'; + +import { processAsyncQueue, type NormalizedOptions } from './shared'; +import { compileSwc } from './swc'; + +const EOL_REGEX = /\r?\n/g; + +function addSourceMappingUrl(code: string, sourceMapLocation: string): string { + // Babel keeps stripping this comment, even when correct option is set. Adding manually. + return code + '\n//# sourceMappingURL=' + sourceMapLocation; +} + +export function hasStylesFilesToProcess(normalizedOptions: NormalizedOptions) { + const files = globSync('**/*.styles.ts', { cwd: normalizedOptions.absoluteSourceRoot }); + return files.length > 0; +} + +export async function compileWithGriffelStylesAOT(options: NormalizedOptions, successCallback: () => Promise) { + const { esmConfig, restOfConfigs } = options.moduleOutput.reduce<{ + esmConfig: NormalizedOptions['moduleOutput'][number] | null; + restOfConfigs: NormalizedOptions['moduleOutput']; + }>( + (acc, outputConfig) => { + if (outputConfig.module === 'es6') { + acc.esmConfig = outputConfig; + return acc; + } + + acc.restOfConfigs.push(outputConfig); + return acc; + }, + { esmConfig: null, restOfConfigs: [] }, + ); + + if (!esmConfig) { + logger.warn('es6 module output not specified. Skipping griffel AOT...'); + const compilationQueue = restOfConfigs.map(outputConfig => { + return compileSwc(outputConfig, options); + }); + return processAsyncQueue(compilationQueue, successCallback); + } + + await compileSwc(esmConfig, options); + await babel(esmConfig, options); + + const compilationQueue = restOfConfigs.map(outputConfig => { + const overriddenSourceRoot = join(options.workspaceRoot, options.project.root); + // we need to override source root to the output path of transpiled ESM+Griffel AOT, because griffel is unable to handle SWC commonjs output + // so instead of transpiling TS(ESM) -> JS(COMMONJS), we transpile JS(ESM + griffel AOT) -> JS(COMMONJS) + const overriddenAbsoluteSourceRoot = join(overriddenSourceRoot, esmConfig.outputPath); + + return compileSwc(outputConfig, { + ...options, + absoluteSourceRoot: overriddenAbsoluteSourceRoot, + }); + }); + + return processAsyncQueue(compilationQueue, successCallback); +} + +async function babel(esmModuleOutput: NormalizedOptions['moduleOutput'][number], normalizedOptions: NormalizedOptions) { + const filesRoot = join(normalizedOptions.absoluteProjectRoot, esmModuleOutput.outputPath); + const files = globSync('**/*.styles.js', { cwd: filesRoot }); + + if (files.length === 0) { + return; + } + + logger.log(`Processing griffel AOT with babel: ${files.length} files`); + + for (const filename of files) { + const filePath = join(filesRoot, filename); + + const codeBuffer = await readFile(filePath); + const sourceCode = codeBuffer.toString().replace(EOL_REGEX, '\n'); + + const result = (await transformAsync(sourceCode, { + ast: false, + sourceMaps: true, + + babelrc: true, + // to avoid leaking of global configs + babelrcRoots: [normalizedOptions.absoluteProjectRoot], + + caller: { name: '@fluentui/workspace-plugin:build' }, + filename: filePath, + + sourceFileName: basename(filename), + })) /* Bad `transformAsync` types. it can be null only if 2nd param is null(config)*/ as NonNullableRecord; + + // FIXME: + // - NOTE: needs to be fixed primarily in {@link 'file://./swc.ts'} as well + // - swc does not add source mapping url when using @swc/core imperative APIs (unlike @swc/cli) (//# sourceMappingURL=) ! + // - we ship transpiled files without proper source mapping since - Wed, 31 May 2023 (the swithc from swc/cli to programatic api useage) + // - @swc/cli does add source mapping because besides invoking swc/core programatically it also contains custom logic to add source mapping url https://github.com/swc-project/pkgs/blob/main/packages/cli/src/swc/compile.ts#L42-L44 + // const resultCode = addSourceMappingUrl(result.code, basename(filename) + '.map'); + + const resultCode = result.code; + + if (resultCode === sourceCode) { + logger.verbose(`babel: skipped ${filePath}`); + continue; + } + logger.verbose(`babel: transformed ${filePath}`); + + const sourceMapFile = filePath + '.map'; + + await writeFile(filePath, resultCode); + await writeFile(sourceMapFile, JSON.stringify(result.map)); + } +} + +type NonNullableRecord = { + [P in keyof T]-?: NonNullable; +}; diff --git a/tools/workspace-plugin/src/executors/build/lib/clean.ts b/tools/workspace-plugin/src/executors/build/lib/clean.ts new file mode 100644 index 00000000000000..ada3d8d001f6d1 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/clean.ts @@ -0,0 +1,25 @@ +import { logger } from '@nx/devkit'; +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { type NormalizedOptions, processAsyncQueue } from './shared'; + +export async function cleanOutput(options: NormalizedOptions, assetFiles: Array<{ input: string; output: string }>) { + if (!options.clean) { + return; + } + + const swcOutputPaths = options.moduleOutput.map(outputConfig => { + return join(options.absoluteProjectRoot, outputConfig.outputPath); + }); + const assetsOutputPaths = assetFiles.map(asset => asset.output); + const outputPaths = [...swcOutputPaths, ...assetsOutputPaths]; + const outputPathsFormattedLog = outputPaths.reduce((acc, outputPath) => { + return `${acc}\n - ${outputPath}`; + }, ''); + + logger.log(`Cleaning outputs:\n ${outputPathsFormattedLog}`); + const result = outputPaths.map(outputPath => rm(outputPath, { recursive: true, force: true })); + + return processAsyncQueue(result); +} diff --git a/tools/workspace-plugin/src/executors/build/lib/shared.ts b/tools/workspace-plugin/src/executors/build/lib/shared.ts new file mode 100644 index 00000000000000..1b329fbc185171 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/shared.ts @@ -0,0 +1,39 @@ +import { type ExecutorContext, logger } from '@nx/devkit'; +import { join } from 'node:path'; + +import { type BuildExecutorSchema } from '../schema'; + +export async function processAsyncQueue(value: Promise[], successCallback?: () => Promise) { + return Promise.all(value) + .then(() => { + return successCallback ? successCallback() : true; + }) + .catch(err => { + logger.error(err); + return false; + }); +} + +export interface NormalizedOptions extends ReturnType {} +export function normalizeOptions(schema: BuildExecutorSchema, context: ExecutorContext) { + const defaults = { + clean: true, + }; + const project = context.projectsConfigurations!.projects[context.projectName!]; + const resolvedSourceRoot = join(project.root, schema.sourceRoot) ?? project.sourceRoot; + const absoluteProjectRoot = join(context.root, project.root); + const absoluteSourceRoot = join(context.root, resolvedSourceRoot); + const absoluteOutputPathRoot = join(context.root, schema.outputPathRoot); + + return { + ...defaults, + ...schema, + project, + sourceRoot: resolvedSourceRoot, + absoluteSourceRoot, + absoluteProjectRoot, + absoluteOutputPathRoot, + + workspaceRoot: context.root, + }; +} diff --git a/tools/workspace-plugin/src/executors/build/lib/swc.ts b/tools/workspace-plugin/src/executors/build/lib/swc.ts new file mode 100644 index 00000000000000..afd89dcaa018c4 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/swc.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { globSync } from 'fast-glob'; +import { isMatch } from 'micromatch'; +import { transform, type Config } from '@swc/core'; +import { logger, readJsonFile } from '@nx/devkit'; + +import { type NormalizedOptions } from './shared'; + +interface Options { + module: 'es6' | 'commonjs' | 'amd'; + outputPath: string; +} + +export async function compileSwc(options: Options, normalizedOptions: NormalizedOptions) { + const { outputPath, module } = options; + const absoluteOutputPath = join(normalizedOptions.absoluteProjectRoot, outputPath); + + logger.log(`Compiling with SWC for module:${options.module}...`); + + const sourceFiles = globSync(`**/*.{js,ts,tsx}`, { cwd: normalizedOptions.absoluteSourceRoot }); + + // TODO: make this configurable via schema + const swcConfigPath = join(normalizedOptions.absoluteProjectRoot, '.swcrc'); + const swcConfig = readJsonFile(swcConfigPath); + const tsFileExtensionRegex = /\.(tsx|ts)$/; + + for (const fileName of sourceFiles) { + const srcFilePath = join(normalizedOptions.absoluteSourceRoot, fileName); + const isFileExcluded = swcConfig.exclude ? isMatch(srcFilePath, swcConfig.exclude, { contains: true }) : false; + + if (isFileExcluded) { + continue; + } + + const sourceCode = readFileSync(srcFilePath, 'utf-8'); + + const result = await transform(sourceCode, { + filename: fileName, + sourceFileName: basename(fileName), + module: { type: module }, + outputPath, + // this is crucial in order to transpile with project config SWC + configFile: swcConfigPath, + }); + + // Strip @jsx comments, see https://github.com/microsoft/fluentui/issues/29126 + const resultCode = result.code + .replace('/** @jsxRuntime automatic */', '') + .replace('/** @jsxImportSource @fluentui/react-jsx-runtime */', ''); + + const jsFileName = fileName.replace(tsFileExtensionRegex, '.js'); + const compiledFilePath = join(absoluteOutputPath, jsFileName); + + // Create directory folder for new compiled file(s) to live in. + await mkdir(dirname(compiledFilePath), { recursive: true }); + + await writeFile(compiledFilePath, resultCode); + + if (result.map) { + await writeFile(`${compiledFilePath}.map`, result.map); + } + } +} diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index f8247abd512051..dcfcdd834be62b 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -1 +1,69 @@ -export interface BuildExecutorSchema {} // eslint-disable-line +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Builds TS files with SWC to specified JS module type, transforms *.styles.* files via Babel(Griffel) and copies provided Assets. + */ +export interface BuildExecutorSchema { + /** + * Directory where the source files are located (relative to {projectRoot}). + */ + sourceRoot: string; + /** + * Root directory where all output assets will live. Relative to {workspaceRoot} + */ + outputPathRoot: string; + /** + * Which JS module type to use to transpile to provided output. + */ + moduleOutput: { + /** + * The module type + */ + module: 'es6' | 'commonjs' | 'amd'; + /** + * The output path of the generated files (relative to {outputPathRoot}) + */ + outputPath: string; + }[]; + /** + * List of static assets. + */ + assets?: ( + | { + /** + * The pattern to match. + */ + glob: string; + /** + * The input directory path in which to apply 'glob' + */ + input: string; + /** + * Absolute path within the output + */ + output: string; + /** + * Key-value pairs to replace in the output path + */ + substitutions?: { + /** + * The key to replace. + */ + key: string; + /** + * The value to replace. + */ + value: string; + }; + } + | string + )[]; + /** + * Remove previous output before build + */ + clean?: boolean; +} diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index bc975d9708a640..71b736d27b2520 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -2,8 +2,66 @@ "$schema": "https://json-schema.org/schema", "version": 2, "title": "Build executor", - "description": "", + "description": "Builds TS files with SWC to specified JS module type, transforms *.styles.* files via Babel(Griffel) and copies provided Assets.", "type": "object", - "properties": {}, - "required": [] + "properties": { + "sourceRoot": { + "type": "string", + "description": "Directory where the source files are located (relative to {projectRoot})." + }, + "outputPathRoot": { + "type": "string", + "description": "Root directory where all output assets will live. Relative to {workspaceRoot}" + }, + "moduleOutput": { + "type": "array", + "description": "Which JS module type to use to transpile to provided output.", + "items": { + "type": "object", + "properties": { + "module": { "type": "string", "enum": ["es6", "commonjs", "amd"], "description": "The module type" }, + "outputPath": { + "type": "string", + "description": "The output path of the generated files (relative to {outputPathRoot})" + } + }, + "required": ["module", "outputPath"] + } + }, + "assets": { + "type": "array", + "description": "List of static assets.", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "glob": { "type": "string", "description": "The pattern to match." }, + "input": { "type": "string", "description": "The input directory path in which to apply 'glob'" }, + "output": { "type": "string", "description": "Absolute path within the output" }, + "substitutions": { + "type": "object", + "description": "Key-value pairs to replace in the output path", + "properties": { + "key": { "type": "string", "description": "The key to replace." }, + "value": { "type": "string", "description": "The value to replace." } + }, + "additionalProperties": false, + "required": ["key", "value"] + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { "type": "string" } + ] + } + }, + "clean": { + "type": "boolean", + "description": "Remove previous output before build", + "default": true + } + }, + "required": ["sourceRoot", "outputPathRoot", "moduleOutput"] }