From 1b5e66461da8632d4953ecd0141e6f33655604d5 Mon Sep 17 00:00:00 2001 From: Tzach Bonfil <45866571+tzachbon@users.noreply.github.com> Date: Tue, 4 Jan 2022 11:00:38 +0200 Subject: [PATCH] feat: support CLI multiple projects (#2086) --- .vscode/launch.json | 3 +- package-lock.json | 67 +- packages/cli/README.md | 180 ++++- packages/cli/package.json | 1 + packages/cli/src/base-generator.ts | 4 +- packages/cli/src/build-single-file.ts | 47 +- packages/cli/src/build-stylable.ts | 103 +++ packages/cli/src/build-tools.ts | 24 - packages/cli/src/build.ts | 397 +++++----- packages/cli/src/cli-codemod.ts | 5 +- packages/cli/src/cli.ts | 76 +- packages/cli/src/code-format.ts | 5 +- packages/cli/src/config/process-projects.ts | 113 +++ packages/cli/src/config/projects-config.ts | 93 +++ .../cli/src/{ => config}/resolve-options.ts | 100 +-- packages/cli/src/config/resolve-requests.ts | 44 ++ packages/cli/src/diagnostics-manager.ts | 111 +++ .../directory-process-service.ts | 153 ++-- packages/cli/src/generate-manifest.ts | 54 +- packages/cli/src/handle-assets.ts | 5 + packages/cli/src/index.ts | 20 +- packages/cli/src/logger.ts | 24 +- packages/cli/src/messages.ts | 40 + packages/cli/src/projects-config.ts | 64 -- packages/cli/src/report-diagnostics.ts | 30 +- packages/cli/src/tsconfig.json | 3 +- packages/cli/src/types.ts | 170 +++++ packages/cli/src/watch-handler.ts | 168 +++++ packages/cli/test/build.spec.ts | 278 ++++--- packages/cli/test/cli-codemod.spec.ts | 3 +- packages/cli/test/cli.spec.ts | 23 +- packages/cli/test/code-format-cli.spec.ts | 2 +- ...bal-custom-property-to-at-property.spec.ts | 3 +- .../codemods/st-import-to-at-import.spec.ts | 3 +- packages/cli/test/config-options.spec.ts | 238 ++++++ packages/cli/test/config-presets.spec.ts | 420 +++++++++++ packages/cli/test/config-projects.spec.ts | 700 ++++++++++++++++++ packages/cli/test/config.spec.ts | 195 ----- .../directory-process-service.spec.ts | 54 +- packages/cli/test/generate-index.spec.ts | 221 ++++-- packages/cli/test/test-kit/cli-test-kit.ts | 133 ---- .../cli/test/watch-multiple-projects.spec.ts | 530 +++++++++++++ ...h.spec.ts => watch-single-project.spec.ts} | 316 +++++--- .../core/src/visit-meta-css-dependencies.ts | 11 +- packages/core/test/stylable-utils.spec.ts | 31 +- packages/e2e-test-kit/src/cli-test-kit.ts | 231 ++++++ packages/e2e-test-kit/src/index.ts | 11 + 47 files changed, 4376 insertions(+), 1131 deletions(-) create mode 100644 packages/cli/src/build-stylable.ts create mode 100644 packages/cli/src/config/process-projects.ts create mode 100644 packages/cli/src/config/projects-config.ts rename packages/cli/src/{ => config}/resolve-options.ts (78%) create mode 100644 packages/cli/src/config/resolve-requests.ts create mode 100644 packages/cli/src/diagnostics-manager.ts create mode 100644 packages/cli/src/messages.ts delete mode 100644 packages/cli/src/projects-config.ts create mode 100644 packages/cli/src/types.ts create mode 100644 packages/cli/src/watch-handler.ts create mode 100644 packages/cli/test/config-options.spec.ts create mode 100644 packages/cli/test/config-presets.spec.ts create mode 100644 packages/cli/test/config-projects.spec.ts delete mode 100644 packages/cli/test/config.spec.ts delete mode 100644 packages/cli/test/test-kit/cli-test-kit.ts create mode 100644 packages/cli/test/watch-multiple-projects.spec.ts rename packages/cli/test/{cli-watch.spec.ts => watch-single-project.spec.ts} (51%) create mode 100644 packages/e2e-test-kit/src/cli-test-kit.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 48c6f9e08..1ceb14ace 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,8 @@ "skipFiles": ["/**"], "resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"], "env": { - "FILE": "${file}" + "FILE": "${file}", + "CLI_WATCH_TEST_TIMEOUT": "999999" } }, { diff --git a/package-lock.json b/package-lock.json index f73818323..9e84fa95d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1070,6 +1070,31 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@wixc3/resolve-directory-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.1.tgz", + "integrity": "sha512-WMYkNH2b4igCWUZwKE84cnnPmJVQ+XT33HnST2mucyrAPqZDgS1Dft/khQBq41Cg02z9eFubOQ/E89ch5zV5iQ==", + "dependencies": { + "find-up": "^5.0.0", + "glob": "^7.1.7", + "tslib": "^2.3.1", + "type-fest": "^2.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wixc3/resolve-directory-context/node_modules/type-fest": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.5.4.tgz", + "integrity": "sha512-zyPomVvb6u7+gJ/GPYUH6/nLDNiTtVOqXVUHtxFv5PmZQh6skgfeRtFYzWC01T5KeNWNIx5/0P111rKFLlkFvA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -3038,7 +3063,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4067,7 +4091,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -5031,7 +5054,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -5046,7 +5068,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -5119,7 +5140,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -6533,8 +6553,7 @@ "node_modules/tslib": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -7206,7 +7225,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, @@ -7237,6 +7255,7 @@ "@stylable/core": "^4.9.5", "@stylable/node": "^4.9.5", "@stylable/optimizer": "^4.9.5", + "@wixc3/resolve-directory-context": "^1.0.1", "lodash.camelcase": "^4.3.0", "lodash.upperfirst": "^4.3.1", "yargs": "^17.3.1" @@ -7782,6 +7801,7 @@ "@stylable/core": "^4.9.5", "@stylable/node": "^4.9.5", "@stylable/optimizer": "^4.9.5", + "@wixc3/resolve-directory-context": "^1.0.1", "lodash.camelcase": "^4.3.0", "lodash.upperfirst": "^4.3.1", "yargs": "^17.3.1" @@ -8637,6 +8657,24 @@ "@xtuc/long": "4.2.2" } }, + "@wixc3/resolve-directory-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.1.tgz", + "integrity": "sha512-WMYkNH2b4igCWUZwKE84cnnPmJVQ+XT33HnST2mucyrAPqZDgS1Dft/khQBq41Cg02z9eFubOQ/E89ch5zV5iQ==", + "requires": { + "find-up": "^5.0.0", + "glob": "^7.1.7", + "tslib": "^2.3.1", + "type-fest": "^2.1.0" + }, + "dependencies": { + "type-fest": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.5.4.tgz", + "integrity": "sha512-zyPomVvb6u7+gJ/GPYUH6/nLDNiTtVOqXVUHtxFv5PmZQh6skgfeRtFYzWC01T5KeNWNIx5/0P111rKFLlkFvA==" + } + } + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -10161,7 +10199,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -10888,7 +10925,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -11599,7 +11635,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -11608,7 +11643,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -11665,8 +11699,7 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, "path-is-absolute": { "version": "1.0.1", @@ -12662,8 +12695,7 @@ "tslib": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" }, "tsutils": { "version": "3.21.0", @@ -13164,8 +13196,7 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index ff161bd98..fdd73fa3b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -23,6 +23,10 @@ yarn add @stylable/cli -D After installing `@stylable/cli`, the `stc` command will be available, running `stc --help` will provide a brief description for the options available. +`stc` accepts [CLI arguments](#cli-arguments) or a Stylable [configuration file](#configuration-file). + +### CLI Arguments + | Option | Alias | Description | Default Value | | ------------------------- | ------ | ---------------------------------------------------------------------------------- | ---------------- | | `--version` | `v` | show CLI version number | `boolean` | @@ -44,7 +48,7 @@ After installing `@stylable/cli`, the `stc` command will be available, running ` | `--cssFilename` | | pattern of the generated css file | `[filename].css` | | `--injectCSSRequest` | `icr` | add a static import for the generated css in the js module output | `false` | | `--namespaceResolver` | `nsr` | node request to a module that exports a stylable resolveNamespace function | `@stylable/node` | -| `--require` | `r` | require hook to execture before running | `-` | +| `--require` | `r` | require hook to execute before running | `-` | | `--optimize` | `o` | removes: empty nodes, stylable directives, comments | `false` | | `--minify` | `m` | minify generated css | `false` | | `--log` | | verbose log | `false` | @@ -54,6 +58,67 @@ After installing `@stylable/cli`, the `stc` command will be available, running ` `*` - For the `useNamespaceReference` flag to function properly, the `source` folder must be published in addition to the output `target` code +### Configuration file + +The `stc` configuration should be located in the `stylable.config.js` file under the property name `stcConfig`. +The CLI provides a helper method and type definitions to provide a better configuration experience. + +```js +const { typedConfiguration } = require('@stylable/cli'); + +// This can be an object or a method that returns an object. +exports.stcConfig = typedConfiguration({ + options: { + // BuildOptions + } +}); +``` + +#### Build options +```ts + +export interface BuildOptions { + /** specify where to find source files */ + srcDir: string; + /** specify where to build the target files */ + outDir: string; + /** should the build need to output manifest file */ + manifest?: string; + /** opt into build index file and specify the filepath for the generated index file */ + indexFile?: string; + /** custom cli index generator class */ + IndexGenerator?: typeof IndexGenerator; + /** output commonjs module (.js) */ + cjs?: boolean; + /** output esm module (.mjs) */ + esm?: boolean; + /** template of the css file emitted when using outputCSS */ + outputCSSNameTemplate?: string; + /** should include the css in the generated JS module */ + includeCSSInJS?: boolean; + /** should output build css for each source file */ + outputCSS?: boolean; + /** should output source .st.css file to dist */ + outputSources?: boolean; + /** should add namespace reference to the .st.css copy */ + useNamespaceReference?: boolean; + /** should inject css import in the JS module for the generated css from outputCSS */ + injectCSSRequest?: boolean; + /** should apply css optimizations */ + optimize?: boolean; + /** should minify css */ + minify?: boolean; + /** should generate .d.ts definitions for every stylesheet */ + dts?: boolean; + /** should generate .d.ts.map files for every .d.ts mapping back to the source .st.css */ + dtsSourceMap?: boolean; + /** should emit diagnostics */ + diagnostics?: boolean; + /** determine the diagnostics mode. if strict process will exit on any exception, loose will attempt to finish the process regardless of exceptions */ + diagnosticsMode?: DiagnosticsMode; +} +``` + ### Generating an index file This generates an `index.st.css` file that acts as an export entry from every stylesheet in the provided `srcDir`. @@ -101,6 +166,119 @@ To transform your project stylesheets to target JavaScript modules containing th $ stc --srcDir="./src" --outDir="./dist" ``` +## Multiple Projects + +Projects allow sharing `stc` configurations and management of Stylable projects in one location. They provides a controllable and predictable build order with caching optimizations. + +```ts +export interface MultipleProjectsConfig { + options?: PartialBuildOptions; + presets?: Presets; + projects: Projects; + projectsOptions?: { + resolveRequests?: ResolveRequests; + }; +} +``` + +> Example for simple monorepo with Stylable packages +```js +const { typedConfiguration } = require('@stylable/cli'); + +exports.stcConfig = typedConfiguration({ + options: { + srcDir: './src', + outDir: './dist', + outputSources: true, + cjs: false, + useNamespaceReference: true, + }, + projects: ['packages/*'] +}); + +``` + +### Options + +Similar to a [single project](#configuration-file), `options` is the top-level `BuildOptions` and is the default options for each project. + +### Projects + +**Projects** is a generic term that refers to a set of path requests that define single or multiple `BuildOptions`.\ +This set of requests is being processed and then evaluated as a map of `projectRoot` (directory path) to a set of `BuildOptions`. + +By default, the request is a path to a package, and in order to make the correct topological sort, the dependency needs to be specified in each package `package.json` + +As mentioned above, the value of a request can be resolved to a single or multiple `BuildOptions`. + +```jsonc +{ + //... + projects: { + "packages/*": { + // ...BuildOptions + }, + "other-package/*": [ + { /* #1 ...BuildOptions */ }, + { /* #2 ...BuildOptions */ }, + ] + } + //... +} +``` + +> The full types specification for defining Projects +```ts +export type Projects = + | Array + | Record; + +export type ProjectEntryValues = + | ProjectEntryValue + | Array>; + +export type ProjectEntryValue = + | PRESET + | PartialBuildOptions + | { + preset?: PRESET; + presets?: Array; + options: PartialBuildOptions; + }; +``` + + + +### Presets + +To reuse `BuildOptions`, define them using a name under the `presets` property and use them as the project entry value. + +```js +exports.stcConfig = { + //... + presets: { + firstPreset: {/* ...BuildOptions */}, + secondPreset: {/* ...BuildOptions */}, + }, + projects: { + 'packages/*': ['firstPreset', 'secondPreset'] + } + +}; +``` + +### Projects Options + +These options control the projects resolution process. + +#### resolveRequests [Function] *(Advanced usage)* + +Default: `resolveNpmRequests` + +This method is used to resolve the Projects `requests` (e.g. 'packages/*' in the example) to the actual `projectRoot`s (absolute path to the relevant projects). + +The order of the resolved entities will be the order of the builds. + ## Usage `stc-format` After installing `@stylable/cli`, the `stc-format` command will be available, running `stc-format --help` will provide a brief description for the options available. diff --git a/packages/cli/package.json b/packages/cli/package.json index fd2e5e06b..c538936c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,6 +18,7 @@ "@stylable/core": "^4.9.5", "@stylable/node": "^4.9.5", "@stylable/optimizer": "^4.9.5", + "@wixc3/resolve-directory-context": "^1.0.1", "lodash.camelcase": "^4.3.0", "lodash.upperfirst": "^4.3.1", "yargs": "^17.3.1" diff --git a/packages/cli/src/base-generator.ts b/packages/cli/src/base-generator.ts index 2a25abb35..1e6c2f070 100644 --- a/packages/cli/src/base-generator.ts +++ b/packages/cli/src/base-generator.ts @@ -15,7 +15,7 @@ export interface ReExports { stVars: Record; } -export class Generator { +export class IndexGenerator { private indexFileOutput = new Map(); private collisionDetector = new NameCollisionDetector(); @@ -95,7 +95,7 @@ export class Generator { } } -export function reExportsAllSymbols(filePath: string, generator: Generator): ReExports { +export function reExportsAllSymbols(filePath: string, generator: IndexGenerator): ReExports { const meta = generator.stylable.process(filePath); const rootExport = generator.filename2varname(filePath); const classes = Object.keys(meta.getAllClasses()) diff --git a/packages/cli/src/build-single-file.ts b/packages/cli/src/build-single-file.ts index c79a73628..309068e7a 100644 --- a/packages/cli/src/build-single-file.ts +++ b/packages/cli/src/build-single-file.ts @@ -1,14 +1,16 @@ -import { isAsset, Stylable } from '@stylable/core'; +import { isAsset, Stylable, StylableResults } from '@stylable/core'; import { createModuleSource, generateDTSContent, generateDTSSourceMap, } from '@stylable/module-utils'; import { StylableOptimizer } from '@stylable/optimizer'; -import { ensureDirectory, handleDiagnostics, tryRun } from './build-tools'; +import { ensureDirectory, tryRun } from './build-tools'; import { nameTemplate } from './name-template'; import type { Log } from './logger'; -import type { DiagnosticMessages } from './report-diagnostics'; +import { DiagnosticsManager, DiagnosticsMode } from './diagnostics-manager'; +import type { Diagnostic } from './report-diagnostics'; +import { errorMessages } from './messages'; export interface BuildCommonOptions { fullOutDir: string; @@ -24,11 +26,13 @@ export interface BuildCommonOptions { mode?: string; dts?: boolean; dtsSourceMap?: boolean; + diagnosticsMode?: DiagnosticsMode; } export interface BuildFileOptions extends BuildCommonOptions { + identifier?: string; stylable: Stylable; - diagnosticsMessages: DiagnosticMessages; + diagnosticsManager: DiagnosticsManager; projectAssets: Set; includeCSSInJS?: boolean; useNamespaceReference?: boolean; @@ -41,6 +45,7 @@ export function buildSingleFile({ fullOutDir, filePath, fullSrcDir, + identifier = fullSrcDir, log, fs, moduleFormats, @@ -52,7 +57,6 @@ export function buildSingleFile({ // build specific stylable, includeCSSInJS = false, - diagnosticsMessages, projectAssets, useNamespaceReference = false, injectCSSRequest = false, @@ -60,6 +64,8 @@ export function buildSingleFile({ minify = false, dts = false, dtsSourceMap, + diagnosticsMode = 'loose', + diagnosticsManager = new DiagnosticsManager({ log }), }: BuildFileOptions) { const { basename, dirname, join, relative, resolve } = fs; const outSrcPath = join(fullOutDir, filePath.replace(fullSrcDir, '')); @@ -78,7 +84,10 @@ export function buildSingleFile({ () => fs.readFileSync(filePath).toString(), `Read File Error: ${filePath}` ); - const res = stylable.transform(content, filePath); + const res = tryRun( + () => stylable.transform(content, filePath), + errorMessages.STYLABLE_PROCESS(filePath) + ); const optimizer = new StylableOptimizer(); if (optimize) { optimizer.optimize( @@ -93,7 +102,15 @@ export function buildSingleFile({ {} ); } - handleDiagnostics(res, diagnosticsMessages, filePath); + + const diagnostics = getAllDiagnostics(res); + if (diagnostics.length) { + diagnosticsManager.set(identifier, filePath, { + diagnosticsMode, + diagnostics, + }); + } + // st.css if (outputSources) { if (outSrcPath === filePath) { @@ -243,3 +260,19 @@ export function removeBuildProducts({ log(mode, `removed: [${outputLogs.join(', ')}]`); } + +export function getAllDiagnostics(res: StylableResults): Diagnostic[] { + const diagnostics = res.meta.transformDiagnostics + ? res.meta.diagnostics.reports.concat(res.meta.transformDiagnostics.reports) + : res.meta.diagnostics.reports; + + return diagnostics.map((diagnostic) => { + const err = diagnostic.node.error(diagnostic.message, diagnostic.options); + + return { + type: diagnostic.type, + message: `${diagnostic.message}\n${err.showSourceCode(true)}`, + offset: diagnostic.node.source?.start?.offset, + }; + }); +} diff --git a/packages/cli/src/build-stylable.ts b/packages/cli/src/build-stylable.ts new file mode 100644 index 000000000..b358d014c --- /dev/null +++ b/packages/cli/src/build-stylable.ts @@ -0,0 +1,103 @@ +import { nodeFs as fs } from '@file-services/node'; +import { Stylable, StylableConfig, StylableResolverCache } from '@stylable/core'; +import { build } from './build'; +import { projectsConfig } from './config/projects-config'; +import { + createBuildIdentifier, + createDefaultOptions, + NAMESPACE_RESOLVER_MODULE_REQUEST, +} from './config/resolve-options'; +import { DiagnosticsManager } from './diagnostics-manager'; +import { createDefaultLogger } from './logger'; +import type { BuildContext, BuildOptions } from './types'; +import { WatchHandler } from './watch-handler'; + +export interface BuildStylableContext + extends Partial>, + Partial> { + resolverCache?: StylableResolverCache; + fileProcessorCache?: StylableConfig['fileProcessorCache']; + diagnosticsManager?: DiagnosticsManager; + outputFiles?: Map>; + defaultOptions?: BuildOptions; + overrideBuildOptions?: Partial; +} + +export async function buildStylable( + rootDir: string, + { + defaultOptions = createDefaultOptions(), + overrideBuildOptions = {}, + fs: fileSystem = fs, + log = createDefaultLogger(), + watch = false, + resolverCache = new Map(), + fileProcessorCache = {}, + diagnosticsManager = new DiagnosticsManager({ + log, + onFatalDiagnostics() { + if (!watch) { + process.exitCode = 1; + } + }, + }), + outputFiles = new Map(), + requireModule = require, + resolveNamespace = requireModule(NAMESPACE_RESOLVER_MODULE_REQUEST).resolveNamespace, + }: BuildStylableContext = {} +) { + const projects = await projectsConfig(rootDir, overrideBuildOptions, defaultOptions); + const watchHandler = new WatchHandler(fileSystem, { + log, + resolverCache, + outputFiles, + rootDir, + diagnosticsManager, + }); + + for (const { projectRoot, options } of projects) { + for (let i = 0; i < options.length; i++) { + const buildOptions = options[i]; + const identifier = createBuildIdentifier( + rootDir, + projectRoot, + i, + options.length > 1, + projects.length > 1 + ); + + log('[Project]', projectRoot, buildOptions); + + const stylable = Stylable.create({ + fileSystem, + requireModule, + projectRoot, + resolveNamespace, + resolverCache, + fileProcessorCache, + }); + + const { service } = await build(buildOptions, { + watch, + stylable, + log, + fs: fileSystem, + rootDir, + projectRoot, + outputFiles, + identifier, + diagnosticsManager, + }); + + watchHandler.register({ service, identifier, stylable }); + } + } + + diagnosticsManager.report(); + + if (watch) { + watchHandler.start(); + } + + return { watchHandler }; +} diff --git a/packages/cli/src/build-tools.ts b/packages/cli/src/build-tools.ts index 9cd657e26..bada0b229 100644 --- a/packages/cli/src/build-tools.ts +++ b/packages/cli/src/build-tools.ts @@ -1,30 +1,6 @@ -import type { StylableResults } from '@stylable/core'; import type { FileSystem } from '@stylable/node'; -import type { DiagnosticMessages } from './report-diagnostics'; import { dirname } from 'path'; -export function handleDiagnostics( - res: StylableResults, - diagnosticsMessages: DiagnosticMessages, - filePath: string -) { - const reports = res.meta.transformDiagnostics - ? res.meta.diagnostics.reports.concat(res.meta.transformDiagnostics.reports) - : res.meta.diagnostics.reports; - if (reports.length) { - diagnosticsMessages.set( - filePath, - reports.map((report) => { - const err = report.node.error(report.message, report.options); - return { - type: report.type, - message: `${report.message}\n${err.showSourceCode()}`, - }; - }) - ); - } -} - export function tryRun(fn: () => T, errorMessage: string): T { try { return fn(); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 3eec32330..712c1fd8a 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,124 +1,79 @@ -import { Stylable, visitMetaCSSDependenciesBFS } from '@stylable/core'; -import type { IFileSystem } from '@file-services/types'; -import { Generator as BaseGenerator } from './base-generator'; +import type { BuildContext, BuildOptions } from './types'; +import { visitMetaCSSDependenciesBFS } from '@stylable/core'; +import { IndexGenerator as BaseIndexGenerator } from './base-generator'; import { generateManifest } from './generate-manifest'; import { handleAssets } from './handle-assets'; import { buildSingleFile, removeBuildProducts } from './build-single-file'; import { DirectoryProcessService } from './directory-process-service/directory-process-service'; -import { levels, Log } from './logger'; -import { DiagnosticMessages, reportDiagnostics } from './report-diagnostics'; +import { DiagnosticsManager } from './diagnostics-manager'; +import type { Diagnostic } from './report-diagnostics'; import { tryRun } from './build-tools'; +import { errorMessages, buildMessages } from './messages'; -export const messages = { - START_WATCHING: 'start watching...', - FINISHED_PROCESSING: 'finished processing', - BUILD_SKIPPED: 'No stylable files found. build skipped.', -}; - -export interface BuildOptions { - /** Specify the extension of stylable files */ - extension: string; - /** provide a custom file-system for the build */ - fs: IFileSystem; - /** provide Stylable instance */ - stylable: Stylable; - /** project root directory */ - rootDir: string; - /** specify where to find source files */ - srcDir: string; - /** specify where to build the target files */ - outDir: string; - /** should the build need to output manifest file */ - manifest?: string; - /** log function */ - log: Log; - /** opt into build index file and specify the filepath for the generated index file */ - indexFile?: string; - /** custom cli index generator class */ - Generator?: typeof BaseGenerator; - /** output commonjs module (.js) */ - cjs?: boolean; - /** output esm module (.mjs) */ - esm?: boolean; - /** template of the css file emitted when using outputCSS */ - outputCSSNameTemplate?: string; - /** should include the css in the generated JS module */ - includeCSSInJS?: boolean; - /** should output build css for each source file */ - outputCSS?: boolean; - /** should output source .st.css file to dist */ - outputSources?: boolean; - /** should add namespace reference to the .st.css copy */ - useNamespaceReference?: boolean; - /** should inject css import in the JS module for the generated css from outputCSS */ - injectCSSRequest?: boolean; - /** should apply css optimizations */ - optimize?: boolean; - /** should minify css */ - minify?: boolean; - /** should generate .d.ts definitions for every stylesheet */ - dts?: boolean; - /** should generate .d.ts.map files for every .d.ts mapping back to the source .st.css */ - dtsSourceMap?: boolean; - /** enable watch mode */ - watch?: boolean; - /** should emit diagnostics */ - diagnostics?: boolean; - /** determine the diagnostics mode. if strict process will exit on any exception, loose will attempt to finish the process regardless of exceptions */ - diagnosticsMode?: 'strict' | 'loose'; -} - -export async function build({ - extension, - fs, - stylable, - rootDir: rootDirPath, - srcDir, - outDir, - log, - indexFile, - Generator = BaseGenerator, - cjs, - esm, - includeCSSInJS, - outputCSS, - outputCSSNameTemplate, - outputSources, - useNamespaceReference, - injectCSSRequest, - optimize, - minify, - manifest, - dts, - dtsSourceMap, - watch, - diagnostics, - diagnosticsMode, -}: BuildOptions) { - const { join, resolve, realpathSync } = fs; - const rootDir = resolve(rootDirPath); - const realRootDir = realpathSync(rootDir); - const fullSrcDir = join(realRootDir, srcDir); - const fullOutDir = join(realRootDir, outDir); - const nodeModules = join(realRootDir, 'node_modules'); +export async function build( + { + srcDir, + outDir, + indexFile, + IndexGenerator = BaseIndexGenerator, + cjs, + esm, + includeCSSInJS, + outputCSS, + outputCSSNameTemplate, + outputSources, + useNamespaceReference, + injectCSSRequest, + optimize, + minify, + manifest, + dts, + dtsSourceMap, + diagnostics, + diagnosticsMode, + }: BuildOptions, + { + projectRoot: _projectRoot, + rootDir: _rootDir, + identifier = _projectRoot, + watch, + fs, + stylable, + log, + outputFiles = new Map(), + diagnosticsManager = new DiagnosticsManager({ log }), + }: BuildContext +) { + const { join, realpathSync } = fs; + const projectRoot = realpathSync(_projectRoot); + const rootDir = realpathSync(_rootDir); + const fullSrcDir = join(projectRoot, srcDir); + const fullOutDir = join(projectRoot, outDir); + const nodeModules = join(projectRoot, 'node_modules'); + const isMultiPackagesProject = projectRoot !== rootDir; - if (rootDir !== realRootDir) { - log(`rootDir is linked:\n${rootDir}\n↳${realRootDir}`); + if (projectRoot !== _projectRoot) { + log(`projectRoot is linked:\n${_projectRoot}\n↳${projectRoot}`); } - validateConfiguration(outputSources, fullOutDir, fullSrcDir); + const mode = watch ? '[Watch]' : '[Build]'; - const generator = new Generator(stylable, log); - const generated = new Set(); + const generator = new IndexGenerator(stylable, log); + const buildGeneratedFiles = new Set(); const sourceFiles = new Set(); const assets = new Set(); - const diagnosticsMessages: DiagnosticMessages = new Map(); const moduleFormats = getModuleFormats({ cjs, esm }); const service = new DirectoryProcessService(fs, { watchMode: watch, autoResetInvalidations: true, + watchOptions: { + skipInitialWatch: true, + }, directoryFilter(dirPath) { - if (!dirPath.startsWith(realRootDir)) { + if (!dirPath.startsWith(fullSrcDir)) { + return false; + } + if (fullSrcDir !== fullOutDir && !indexFile && dirPath.startsWith(fullOutDir)) { return false; } if (dirPath.startsWith(nodeModules) || dirPath.includes('.git')) { @@ -127,7 +82,7 @@ export async function build({ return true; }, fileFilter(filePath) { - if (generated.has(filePath)) { + if (buildGeneratedFiles.has(filePath)) { return false; } if (!indexFile && outputSources && filePath.startsWith(fullOutDir)) { @@ -137,8 +92,11 @@ export async function build({ if (assets.has(filePath)) { return true; } + if (!filePath.startsWith(fullSrcDir)) { + return false; + } // stylable files - return filePath.endsWith(extension); + return filePath.endsWith('.st.css'); }, onError(error) { if (watch) { @@ -147,10 +105,8 @@ export async function build({ throw error; } }, - processFiles(service, affectedFiles, deletedFiles, changeOrigin) { + processFiles(_, affectedFiles, deletedFiles, changeOrigin) { if (changeOrigin) { - // watched file changed, invalidate cache - stylable.initCache(); // handle deleted files by removing their generated content if (deletedFiles.size) { for (const deletedFile of deletedFiles) { @@ -160,7 +116,7 @@ export async function build({ } else if (!sourceFiles.has(deletedFile)) { continue; } - diagnosticsMessages.delete(deletedFile); + diagnosticsManager.delete(identifier, deletedFile); sourceFiles.delete(deletedFile); generator.removeEntryFromIndex(deletedFile, fullOutDir); removeBuildProducts({ @@ -169,11 +125,11 @@ export async function build({ filePath: deletedFile, log, fs, - moduleFormats: moduleFormats || [], + moduleFormats, outputCSS, outputCSSNameTemplate, outputSources, - generated, + generated: buildGeneratedFiles, dts, dtsSourceMap, }); @@ -181,88 +137,167 @@ export async function build({ } } - // add files that contains errors for retry - for (const filePath of diagnosticsMessages.keys()) { - affectedFiles.add(filePath); + const processGeneratedFiles = new Set(); + const diagnosedFiles = Array.from(diagnosticsManager.get(identifier)?.keys() || []); + + if (diagnosedFiles.length) { + // add files that contains errors for retry + for (const filePath of diagnosedFiles) { + affectedFiles.add(filePath); + } + + diagnosticsManager.delete(identifier); } - diagnosticsMessages.clear(); - // remove assets from the affected files (handled in buildAggregatedEntities) for (const filePath of affectedFiles) { + if (!indexFile) { + // map st output file path to src file path + outputFiles.set(filePath.replace(fullSrcDir, fullOutDir), new Set([filePath])); + } + + // remove assets from the affected files (handled in buildAggregatedEntities) if (assets.has(filePath)) { affectedFiles.delete(filePath); } } // rebuild - buildFiles(affectedFiles); + buildFiles(affectedFiles, processGeneratedFiles); // rewire invalidations - updateWatcherDependencies(stylable, service, affectedFiles, sourceFiles); + updateWatcherDependencies(affectedFiles); // rebuild assets from aggregated content: index files and assets - buildAggregatedEntities(); - // report build diagnostics - reportDiagnostics(diagnosticsMessages, diagnostics, diagnosticsMode); + buildAggregatedEntities(affectedFiles, processGeneratedFiles); + + if (!diagnostics) { + diagnosticsManager.delete(identifier); + } + + const count = deletedFiles.size + affectedFiles.size + assets.size; - const count = deletedFiles.size + affectedFiles.size; - log( - mode, - `${messages.FINISHED_PROCESSING} ${count} ${count === 1 ? 'file' : 'files'}${ - changeOrigin ? ', watching...' : '' - }`, - levels.info - ); + if (count) { + log( + mode, + buildMessages.FINISHED_PROCESSING( + count, + isMultiPackagesProject ? identifier : undefined + ) + ); + } + + // merge the current process generated files with the total build generated files + for (const generatedFile of processGeneratedFiles) { + buildGeneratedFiles.add(generatedFile); + } + + return { generatedFiles: processGeneratedFiles }; }, }); await service.init(fullSrcDir); - if (watch) { - log(mode, messages.START_WATCHING, levels.info); - } else if (sourceFiles.size === 0) { - log(mode, messages.BUILD_SKIPPED, levels.info); + if (sourceFiles.size === 0) { + log(mode, buildMessages.BUILD_SKIPPED(isMultiPackagesProject ? identifier : undefined)); } - return { diagnosticsMessages }; + return { service }; - function buildFiles(filesToBuild: Set) { + function buildFiles(filesToBuild: Set, generated: Set) { for (const filePath of filesToBuild) { - if (indexFile) { - generator.generateFileIndexEntry(filePath, fullOutDir); + try { + if (indexFile) { + generator.generateFileIndexEntry(filePath, fullOutDir); + } else { + buildSingleFile({ + fullOutDir, + filePath, + fullSrcDir, + log, + fs, + stylable, + diagnosticsManager, + diagnosticsMode, + identifier, + projectAssets: assets, + moduleFormats, + includeCSSInJS, + outputCSS, + outputCSSNameTemplate, + outputSources, + useNamespaceReference, + injectCSSRequest, + optimize, + dts, + dtsSourceMap, + minify, + generated, + }); + } + } catch (error) { + setFileErrorDiagnostic(filePath, error); + } + } + } + + function updateWatcherDependencies(affectedFiles: Set) { + const resolver = stylable.createResolver(); + for (const filePath of affectedFiles) { + try { + sourceFiles.add(filePath); + const meta = tryRun( + () => stylable.process(filePath), + errorMessages.STYLABLE_PROCESS(filePath) + ); + visitMetaCSSDependenciesBFS( + meta, + ({ source }) => registerInvalidation(source, filePath), + resolver, + (resolvedPath) => registerInvalidation(resolvedPath, filePath) + ); + } catch (error) { + setFileErrorDiagnostic(filePath, error); + } + } + + function registerInvalidation(source: string, filePath: string) { + if (outputFiles.has(source)) { + for (const sourceFile of outputFiles.get(source)!) { + service.registerInvalidateOnChange(sourceFile, filePath); + } } else { - buildSingleFile({ - fullOutDir, - filePath, - fullSrcDir, - log, - fs, - stylable, - diagnosticsMessages, - projectAssets: assets, - moduleFormats: moduleFormats || [], - includeCSSInJS, - outputCSS, - outputCSSNameTemplate, - outputSources, - useNamespaceReference, - injectCSSRequest, - optimize, - dts, - dtsSourceMap, - minify, - generated, - }); + service.registerInvalidateOnChange(source, filePath); } } } - function buildAggregatedEntities() { + function setFileErrorDiagnostic(filePath: string, error: any) { + const diangostic: Diagnostic = { + type: 'error', + message: error instanceof Error ? error.message : String(error), + }; + + diagnosticsManager.set(identifier, filePath, { + diagnosticsMode, + diagnostics: [diangostic], + }); + } + + function buildAggregatedEntities(affectedFiles: Set, generated: Set) { if (indexFile) { const indexFilePath = join(fullOutDir, indexFile); generated.add(indexFilePath); generator.generateIndexFile(fs, indexFilePath); + + outputFiles.set(indexFilePath, affectedFiles); } else { - handleAssets(assets, realRootDir, srcDir, outDir, fs); - generateManifest(realRootDir, sourceFiles, manifest, stylable, mode, log, fs); + const generatedAssets = handleAssets(assets, projectRoot, srcDir, outDir, fs); + for (const generatedAsset of generatedAssets) { + generated.add(generatedAsset); + } + + if (manifest) { + generateManifest(projectRoot, sourceFiles, manifest, stylable, mode, log, fs); + generated.add(manifest); + } } } } @@ -270,7 +305,7 @@ export async function build({ export function createGenerator( root: string, generatorPath?: string -): undefined | typeof BaseGenerator { +): undefined | typeof BaseIndexGenerator { if (!generatorPath) { return undefined; } @@ -278,42 +313,14 @@ export function createGenerator( const absoluteGeneratorPath = require.resolve(generatorPath, { paths: [root] }); return tryRun(() => { - const generatorModule: { Generator: typeof BaseGenerator } = require(absoluteGeneratorPath); + const generatorModule: { + Generator: typeof BaseIndexGenerator; + } = require(absoluteGeneratorPath); return generatorModule.Generator; }, `Could not resolve custom generator from "${absoluteGeneratorPath}"`); } -function validateConfiguration(outputSources: boolean | undefined, outDir: string, srcDir: string) { - if (outputSources && srcDir === outDir) { - throw new Error( - 'Invalid configuration: When using "stcss" outDir and srcDir must be different.' + - `\noutDir: ${outDir}` + - `\nsrcDir: ${srcDir}` - ); - } -} - -function updateWatcherDependencies( - stylable: Stylable, - service: DirectoryProcessService, - affectedFiles: Set, - sourceFiles: Set -) { - const resolver = stylable.createResolver(); - for (const filePath of affectedFiles) { - sourceFiles.add(filePath); - const meta = stylable.process(filePath); - visitMetaCSSDependenciesBFS( - meta, - ({ source }) => { - service.registerInvalidateOnChange(source, filePath); - }, - resolver - ); - } -} - function getModuleFormats({ esm, cjs }: { [k: string]: boolean | undefined }) { const formats: Array<'esm' | 'cjs'> = []; if (esm) { diff --git a/packages/cli/src/cli-codemod.ts b/packages/cli/src/cli-codemod.ts index 96e5b1919..3482accc2 100644 --- a/packages/cli/src/cli-codemod.ts +++ b/packages/cli/src/cli-codemod.ts @@ -54,7 +54,10 @@ for (const request of requires) { } } -const log = createLogger('[CodeMod]', true); +const log = createLogger( + (_, ...messages) => console.log('[CodeMod]', ...messages), + () => console.clear() +); const loadedMods = new Set<{ id: string; apply: CodeMod }>(); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 53e35bfb0..a45a5cbc5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,18 +1,26 @@ #!/usr/bin/env node -import { nodeFs } from '@file-services/node'; -import { Stylable } from '@stylable/core'; -import { build } from './build'; +import { nodeFs as fs } from '@file-services/node'; +import { buildStylable } from './build-stylable'; +import { createDefaultOptions, getCliArguments, resolveCliOptions } from './config/resolve-options'; import { createLogger } from './logger'; -import { projectsConfig } from './projects-config'; -import { getCliArguments } from './resolve-options'; async function main() { const argv = getCliArguments(); - const log = createLogger('[Stylable]', argv.log ?? false); - - log('[CLI Arguments]', argv); - - const { watch, require: requires } = argv; + const { resolve } = fs; + const { watch, require: requires, log: shouldLog, namespaceResolver } = argv; + const { resolveNamespace } = require(namespaceResolver); + const rootDir = resolve(argv.rootDir); + + // + const log = createLogger( + (level, ...messages) => { + if (shouldLog || level === 'info') { + const currentTime = new Date().toLocaleTimeString(); + console.log('[Stylable]', `[${currentTime}]`, ...messages); + } + }, + () => !shouldLog && console.clear() + ); // execute all require hooks before running the CLI build for (const request of requires) { @@ -21,36 +29,24 @@ async function main() { } } - const projects = projectsConfig(argv); - const resolverCache = new Map(); - - for (const [projectRoot, options] of Object.entries(projects)) { - const { dts, dtsSourceMap } = options; - - log('[Project]', projectRoot, options); - - if (!dts && dtsSourceMap) { - throw new Error(`"dtsSourceMap" requires turning on "dts"`); - } - - const fileSystem = nodeFs; - const stylable = Stylable.create({ - fileSystem, - requireModule: require, - projectRoot, - resolveNamespace: require(argv.namespaceResolver).resolveNamespace, - resolverCache, - }); - - await build({ - watch, - stylable, - log, - fs: fileSystem, - rootDir: projectRoot, - ...options, - }); - } + const defaultOptions = createDefaultOptions(); + const overrideBuildOptions = resolveCliOptions(argv, defaultOptions); + const { watchHandler } = await buildStylable(rootDir, { + overrideBuildOptions, + defaultOptions, + fs, + resolveNamespace, + watch, + log, + }); + + process.on('SIGTERM', () => { + void watchHandler.stop(); + }); + + process.on('SIGINT', () => { + void watchHandler.stop(); + }); } main().catch((e) => { diff --git a/packages/cli/src/code-format.ts b/packages/cli/src/code-format.ts index de25ad0c6..ee5bf4165 100644 --- a/packages/cli/src/code-format.ts +++ b/packages/cli/src/code-format.ts @@ -105,7 +105,10 @@ const { silent, } = argv; -const log = createLogger('[Stylable code formatter]', true); +const log = createLogger( + (_, ...messages) => console.log('[Stylable code formatter]', ...messages), + () => console.clear() +); if (debug) { log('[Arguments]', argv); diff --git a/packages/cli/src/config/process-projects.ts b/packages/cli/src/config/process-projects.ts new file mode 100644 index 000000000..71778c775 --- /dev/null +++ b/packages/cli/src/config/process-projects.ts @@ -0,0 +1,113 @@ +import type { + BuildOptions, + MultipleProjectsConfig, + PartialBuildOptions, + Presets, + ProjectEntryValue, + ProjectEntryValues, + RawProjectEntity, +} from '../types'; +import { createDefaultOptions, mergeBuildOptions, validateOptions } from './resolve-options'; + +interface ProcessProjectsOptions { + defaultOptions?: BuildOptions; +} + +export function processProjects

( + { projects, presets }: MultipleProjectsConfig

, + { defaultOptions = createDefaultOptions() }: ProcessProjectsOptions = {} +) { + const entities: RawProjectEntity[] = []; + if (!Array.isArray(projects) && typeof projects !== 'object') { + throw new Error('Invalid projects type'); + } + + if (Array.isArray(projects)) { + for (const entry of projects) { + entities.push( + resolveProjectEntry( + typeof entry === 'string' ? [entry] : entry, + defaultOptions, + presets + ) + ); + } + } else if (typeof projects === 'object') { + for (const entry of Object.entries>(projects)) { + entities.push(resolveProjectEntry(entry, defaultOptions, presets)); + } + } + + return { + entities, + }; +} + +function resolveProjectEntry

( + [request, value]: [string, ProjectEntryValues

] | [string], + configOptions: BuildOptions, + availablePresets: Presets = {} +): RawProjectEntity { + const totalOptions: Array = []; + + if (!value) { + totalOptions.push({ ...configOptions }); + } else if (Array.isArray(value)) { + for (const valueEntry of value) { + totalOptions.push(...normalizeEntry(valueEntry)); + } + } else { + totalOptions.push(...normalizeEntry(value)); + } + + request = request.trim(); + return { + request, + options: totalOptions.map((options, i, { length }) => { + const mergedOptions = mergeBuildOptions(configOptions, options); + + validateOptions(mergedOptions, length > 1 ? `[${i}] ${request}` : request); + + return mergedOptions; + }), + }; + + function normalizeEntry(entryValue: ProjectEntryValue

) { + if (typeof entryValue === 'string') { + return [resolvePreset(entryValue, availablePresets)]; + } else if (typeof entryValue === 'object') { + if ('options' in entryValue) { + const currentPresets = entryValue.presets || []; + + if (typeof entryValue.preset === 'string') { + currentPresets.push(entryValue.preset); + } + + return currentPresets.map((presetName) => + mergeBuildOptions( + configOptions, + resolvePreset(presetName, availablePresets), + entryValue.options || {} + ) + ); + } else { + return [entryValue]; + } + } else { + throw new Error(`Cannot resolve entry "${entryValue}"`); + } + } +} + +function resolvePreset( + presetName: string, + availablePresets: NonNullable['presets']> +): BuildOptions | PartialBuildOptions { + const preset = availablePresets[presetName]; + + if (!preset || typeof presetName !== 'string') { + throw new Error(`Cannot resolve preset named "${presetName}"`); + } + + return preset; +} diff --git a/packages/cli/src/config/projects-config.ts b/packages/cli/src/config/projects-config.ts new file mode 100644 index 000000000..a174dddda --- /dev/null +++ b/packages/cli/src/config/projects-config.ts @@ -0,0 +1,93 @@ +import { loadStylableConfig } from '@stylable/build-tools'; +import type { BuildOptions } from '../types'; +import { tryRun } from '../build-tools'; +import type { + Configuration, + ConfigurationProvider, + MultipleProjectsConfig, + RawProjectEntity, + ResolveProjectsContext, + ResolveRequests, + STCConfig, +} from '../types'; +import { processProjects } from './process-projects'; +import { createDefaultOptions, mergeBuildOptions, validateOptions } from './resolve-options'; +import { resolveNpmRequests } from './resolve-requests'; + +export async function projectsConfig( + rootDir: string, + overrideBuildOptions: Partial, + defaultOptions: BuildOptions = createDefaultOptions() +): Promise { + const configFile = resolveConfigFile(rootDir); + const topLevelOptions = mergeBuildOptions( + defaultOptions, + configFile?.options, + overrideBuildOptions + ); + + validateOptions(topLevelOptions); + + let projects: STCConfig; + + if (isMultpleConfigProject(configFile)) { + const { entities } = processProjects(configFile, { + defaultOptions: topLevelOptions, + }); + + projects = await resolveProjectsRequests({ + rootDir, + entities, + resolveRequests: configFile.projectsOptions?.resolveRequests ?? resolveNpmRequests, + }); + } else { + projects = [ + { + projectRoot: rootDir, + options: [topLevelOptions], + }, + ]; + } + + return projects; +} + +export function resolveConfigFile(context: string) { + return loadStylableConfig(context, (config) => + tryRun( + () => + isSTCConfig(config) + ? typeof config.stcConfig === 'function' + ? config.stcConfig() + : config.stcConfig + : undefined, + 'Failed to evaluate "stcConfig"' + ) + ); +} + +function isSTCConfig(config: any): config is { stcConfig: Configuration | ConfigurationProvider } { + return ( + typeof config === 'object' && + config.stcConfig && + (typeof config.stcConfig === 'function' || typeof config.stcConfig === 'object') + ); +} + +function isMultpleConfigProject(config: any): config is MultipleProjectsConfig { + return Boolean(config?.projects); +} + +async function resolveProjectsRequests({ + entities, + rootDir, + resolveRequests, +}: { + rootDir: string; + entities: Array; + resolveRequests: ResolveRequests; +}): Promise { + const context: ResolveProjectsContext = { rootDir }; + + return resolveRequests(entities, context); +} diff --git a/packages/cli/src/resolve-options.ts b/packages/cli/src/config/resolve-options.ts similarity index 78% rename from packages/cli/src/resolve-options.ts rename to packages/cli/src/config/resolve-options.ts index 556767b8f..21534be54 100644 --- a/packages/cli/src/resolve-options.ts +++ b/packages/cli/src/config/resolve-options.ts @@ -1,39 +1,13 @@ import { nodeFs } from '@file-services/node'; import type { Arguments } from 'yargs'; import yargs from 'yargs'; -import { createGenerator } from './build'; -import type { ConfigOptions, PartialConfigOptions } from './projects-config'; +import { createGenerator } from '../build'; +import { removeUndefined } from '../helpers'; +import type { CliArguments, BuildOptions, PartialBuildOptions } from '../types'; const { join } = nodeFs; -export interface CliArguments { - rootDir: string; - srcDir: string | undefined; - outDir: string | undefined; - esm: boolean | undefined; - cjs: boolean | undefined; - css: boolean | undefined; - stcss: boolean | undefined; - dts: boolean | undefined; - dtsSourceMap: boolean | undefined; - useNamespaceReference: boolean | undefined; - namespaceResolver: string; - injectCSSRequest: boolean | undefined; - cssFilename: string | undefined; - cssInJs: boolean | undefined; - optimize: boolean | undefined; - minify: boolean | undefined; - indexFile: string | undefined; - manifest: boolean | undefined; - manifestFilepath: string; - customGenerator: string | undefined; - ext: string | undefined; - require: string[]; - log: boolean | undefined; - diagnostics: boolean | undefined; - diagnosticsMode: string | undefined; - watch: boolean; -} +export const NAMESPACE_RESOLVER_MODULE_REQUEST = '@stylable/node'; export function getCliArguments(): Arguments { const defaults = createDefaultOptions(); @@ -96,7 +70,7 @@ export function getCliArguments(): Arguments { description: 'node request to a module that exports a stylable resolveNamespace function', alias: 'nsr', - default: '@stylable/node', + default: NAMESPACE_RESOLVER_MODULE_REQUEST, }) .option('injectCSSRequest', { type: 'boolean', @@ -144,11 +118,6 @@ export function getCliArguments(): Arguments { type: 'string', description: 'path to file containing indexFile output override methods', }) - .option('ext', { - type: 'string', - description: 'extension of stylable css files', - defaultDescription: String(defaults.extension), - }) .option('require', { type: 'array', description: 'require hooks', @@ -187,17 +156,13 @@ export function getCliArguments(): Arguments { .parseSync(); } -export function resolveCliOptions( - argv: CliArguments, - defaults: ConfigOptions -): PartialConfigOptions { +export function resolveCliOptions(argv: CliArguments, defaults: BuildOptions): PartialBuildOptions { const rootDir = argv.rootDir; const outDir = argv.outDir ?? defaults.outDir; return { outDir: argv.outDir, srcDir: argv.srcDir, - extension: argv.ext, indexFile: argv.indexFile, esm: argv.esm, cjs: argv.cjs, @@ -213,16 +178,15 @@ export function resolveCliOptions( includeCSSInJS: argv.cssInJs, outputSources: argv.stcss, outputCSSNameTemplate: argv.cssFilename, - diagnosticsMode: argv.diagnosticsMode as ConfigOptions['diagnosticsMode'], - Generator: createGenerator(rootDir, argv.customGenerator), + diagnosticsMode: argv.diagnosticsMode as BuildOptions['diagnosticsMode'], + IndexGenerator: createGenerator(rootDir, argv.customGenerator), }; } -export function createDefaultOptions(): ConfigOptions { +export function createDefaultOptions(): BuildOptions { return { outDir: '.', srcDir: '.', - extension: '.st.css', cjs: true, esm: false, dts: false, @@ -239,3 +203,49 @@ export function createDefaultOptions(): ConfigOptions { diagnosticsMode: 'strict', }; } + +export function validateOptions( + { outDir, srcDir, outputSources, dts, dtsSourceMap }: BuildOptions, + name?: string +) { + const prefix = name ? `"${name}" options - ` : ''; + + if (!dts && dtsSourceMap) { + throw new Error(prefix + `"dtsSourceMap" requires turning on "dts"`); + } + + if (outputSources && srcDir === outDir) { + throw new Error( + prefix + + 'Invalid configuration: When using "stcss" outDir and srcDir must be different.' + + `\noutDir: ${outDir}` + + `\nsrcDir: ${srcDir}` + ); + } +} + +export function mergeBuildOptions( + ...configs: [BuildOptions, ...(BuildOptions | PartialBuildOptions | undefined)[]] +): BuildOptions { + const [config, ...rest] = configs; + + return Object.assign( + {}, + config, + ...rest.map((currentConfig) => (currentConfig ? removeUndefined(currentConfig) : {})) + ); +} + +export function createBuildIdentifier( + rootDir: string, + projectRoot: string, + index: number, + hasMultipleOptions: boolean, + isMultipleProjects: boolean +) { + return hasMultipleOptions + ? `[${index}] ${projectRoot.replace(rootDir, '')}` + : isMultipleProjects + ? projectRoot.replace(rootDir, '') + : projectRoot; +} diff --git a/packages/cli/src/config/resolve-requests.ts b/packages/cli/src/config/resolve-requests.ts new file mode 100644 index 000000000..ed87b4029 --- /dev/null +++ b/packages/cli/src/config/resolve-requests.ts @@ -0,0 +1,44 @@ +import { + INpmPackage, + resolveWorkspacePackages, + sortPackagesByDepth, +} from '@wixc3/resolve-directory-context'; +import type { RawProjectEntity, ResolveRequests } from '../types'; + +export const resolveNpmRequests: ResolveRequests = (entities, { rootDir }) => { + const entitiesMap = new Map(); + const packages = new Set(); + + for (const entity of entities) { + const { request } = entity; + const workspacePackages = resolveWorkspacePackages(rootDir, [request]); + + if (!workspacePackages.length) { + throw new Error(`Stylable CLI config can not resolve project request "${request}"`); + } + + for (const pkg of workspacePackages) { + const existingEntity = entitiesMap.get(pkg.displayName); + + // adding the npm package once to keep the original package order and to avoid duplicates + if (existingEntity) { + // validate duplicate requests, e.g. "packages/*" twice + if (existingEntity.request === request) { + throw new Error( + `Stylable CLI config can not have a duplicate project requests "${request}".` + ); + } + } else { + packages.add(pkg); + } + + // adding to entities map and overriding the correct package's entity if exists + entitiesMap.set(pkg.displayName, entity); + } + } + + return sortPackagesByDepth(Array.from(packages)).map((pkg) => ({ + projectRoot: pkg.directoryPath, + options: entitiesMap.get(pkg.displayName)!.options, + })); +}; diff --git a/packages/cli/src/diagnostics-manager.ts b/packages/cli/src/diagnostics-manager.ts new file mode 100644 index 000000000..e26b97da3 --- /dev/null +++ b/packages/cli/src/diagnostics-manager.ts @@ -0,0 +1,111 @@ +import { createDefaultLogger, Log } from './logger'; +import { Diagnostic, DiagnosticMessages, reportDiagnostics } from './report-diagnostics'; + +export type DiagnosticsMode = 'strict' | 'loose'; + +interface ProcessDiagnostics { + diagnostics: Diagnostic[]; + diagnosticsMode?: DiagnosticsMode | undefined; +} + +type DiagnosticsStore = Map>; + +interface DiagnosticsManagerOptions { + log?: Log; + onFatalDiagnostics?: () => void; +} + +export class DiagnosticsManager { + private store: DiagnosticsStore = new Map(); + private log: Log; + + constructor(private options: DiagnosticsManagerOptions = {}) { + this.log = this.options.log ?? createDefaultLogger(); + } + + public clear() { + this.store = new Map(); + } + + public set( + identifier: string, + filepath?: string, + processDiagnostics?: ProcessDiagnostics + ): void { + if (this.store.has(identifier) && filepath && processDiagnostics) { + this.store.get(identifier)!.set(filepath, processDiagnostics); + } else { + this.store.set( + identifier, + new Map( + filepath && processDiagnostics ? [[filepath, processDiagnostics]] : undefined + ) + ); + } + } + + public get(identifier: string): Map | undefined; + public get(identifier: string, filepath: string): ProcessDiagnostics | undefined; + public get(identifier: string, filepath?: string) { + if (filepath) { + return this.store.get(identifier)?.get(filepath); + } else { + return this.store.get(identifier); + } + } + + public delete(identifier: string, filepath?: string) { + if (filepath) { + this.store.get(identifier)?.delete(filepath); + } else { + this.store.delete(identifier); + } + } + + public report() { + let diagnosticMode: DiagnosticsMode = 'loose'; + const diagnosticMessages: DiagnosticMessages = new Map(); + const collectedDiagnostics = new Map>(); + + for (const buildDiagnostics of this.store.values()) { + for (const [ + filePath, + { diagnostics, diagnosticsMode: currentMode }, + ] of buildDiagnostics) { + if (diagnosticMode !== 'strict') { + diagnosticMode = currentMode || diagnosticMode; + } + + if (!diagnosticMessages.has(filePath)) { + diagnosticMessages.set(filePath, []); + collectedDiagnostics.set(filePath, new Map()); + } + + const currentDiagnostics = diagnosticMessages.get(filePath)!; + const ids = collectedDiagnostics.get(filePath)!; + + for (const diagnostic of diagnostics) { + const diagnosticId = `${diagnostic.type};${diagnostic.message}`; + if (!ids.has(diagnosticId)) { + ids.set(diagnosticId, diagnostic); + currentDiagnostics.push(ids.get(diagnosticId)!); + } + } + } + } + + if (diagnosticMessages.size) { + const hasFatalDiangostics = reportDiagnostics( + this.log, + diagnosticMessages, + diagnosticMode + ); + + if (hasFatalDiangostics) { + this.options.onFatalDiagnostics?.(); + } + } + + return Boolean(diagnosticMessages.size); + } +} diff --git a/packages/cli/src/directory-process-service/directory-process-service.ts b/packages/cli/src/directory-process-service/directory-process-service.ts index 45a3d5ef5..a6d130d4c 100644 --- a/packages/cli/src/directory-process-service/directory-process-service.ts +++ b/packages/cli/src/directory-process-service/directory-process-service.ts @@ -1,3 +1,4 @@ +import nodeFs from '@file-services/node'; import type { IFileSystem, IWatchEvent } from '@file-services/types'; import { directoryDeepChildren, DirectoryItem } from './walk-fs'; @@ -7,25 +8,35 @@ export interface DirectoryProcessServiceOptions { affectedFiles: Set, deletedFiles: Set, changeOrigin?: IWatchEvent - ): Promise | void; + ): Promise<{ generatedFiles: Set }> | { generatedFiles: Set }; directoryFilter?(directoryPath: string): boolean; fileFilter?(filePath: string): boolean; onError?(error: Error): void; autoResetInvalidations?: boolean; watchMode?: boolean; + watchOptions?: { + skipInitialWatch?: boolean; + }; } export class DirectoryProcessService { public invalidationMap = new Map>(); public watchedDirectoryFiles = new Map>(); constructor(private fs: IFileSystem, private options: DirectoryProcessServiceOptions = {}) { - if (this.options.watchMode) { - this.fs.watchService.addGlobalListener(this.watchHandler); + if (this.options.watchMode && !this.options.watchOptions?.skipInitialWatch) { + this.startWatch(); } } + public startWatch() { + this.fs.watchService.addGlobalListener(this.watchHandler); + } public async dispose() { + for (const path of this.watchedDirectoryFiles.keys()) { + await this.fs.watchService.unwatchPath(path); + } + this.invalidationMap.clear(); - await this.fs.watchService.unwatchAllPaths(); + this.watchedDirectoryFiles.clear(); } public async init(directoryPath: string) { await this.watchPath(directoryPath); @@ -44,7 +55,7 @@ export class DirectoryProcessService { return; } try { - return this.options.processFiles?.(this, affectedFiles, new Set()); + await this.options.processFiles?.(this, affectedFiles, new Set()); } catch (error) { this.options.onError?.(error as Error); } @@ -86,62 +97,84 @@ export class DirectoryProcessService { this.watchedDirectoryFiles.set(directoryPath, new Set()); return this.fs.watchService.watchPath(directoryPath); } - private async handleWatchChange(event: IWatchEvent) { - if (event.stats?.isDirectory()) { - if (this.options.directoryFilter?.(event.path) ?? true) { - return this.init(event.path); - } - return; - } - if ( - !this.invalidationMap.has(event.path) && - (this.options.fileFilter?.(event.path) ?? true) - ) { - this.registerInvalidateOnChange(event.path); - } - if (this.invalidationMap.has(event.path)) { - const affectedFiles = this.getAffectedFiles(event.path); - const deletedFiles = new Set(); - if (!event.stats) { - this.invalidationMap.delete(event.path); - this.removeFileFromWatchedDirectory(event.path); - deletedFiles.add(event.path); - affectedFiles.delete(event.path); - } - if (this.options.autoResetInvalidations) { - for (const filePath of affectedFiles) { - const invalidationSet = this.invalidationMap.get(filePath); - invalidationSet?.clear(); + public async handleWatchChange( + files: Map, + originalEvent: IWatchEvent + ): Promise<{ + hasChanges: boolean; + generatedFiles: Set; + }> { + const affectedFiles = new Set(); + const deletedFiles = new Set(); + + for (const event of files.values()) { + if (event.stats?.isDirectory()) { + if (this.options.directoryFilter?.(event.path) ?? true) { + await this.init(event.path); } + continue; } - return this.options.processFiles?.(this, affectedFiles, deletedFiles, event); - } else if (!event.stats) { - const fileSet = new Set(); - for (const [dirPath, files] of this.watchedDirectoryFiles) { - if (dirPath.startsWith(event.path)) { - for (const filePath of files) { - fileSet.add(filePath); - } + + if (this.options.fileFilter?.(event.path) ?? true) { + if (event.stats) { + this.registerInvalidateOnChange(event.path); + affectedFiles.add(event.path); + } else { + this.invalidationMap.delete(event.path); + this.removeFileFromWatchedDirectory(event.path); + deletedFiles.add(event.path); } - } - if (fileSet.size) { - const affectedFiles = new Set(); - const deletedFiles = new Set(); - for (const filePath of fileSet) { - this.getAffectedFiles(filePath, affectedFiles); + if (this.options.autoResetInvalidations) { + for (const filePath of affectedFiles) { + const invalidationSet = this.invalidationMap.get(filePath); + invalidationSet?.clear(); + } } - for (const filePath of fileSet) { - this.invalidationMap.delete(filePath); - this.removeFileFromWatchedDirectory(filePath); - deletedFiles.add(filePath); - affectedFiles.delete(filePath); + } else if (!event.stats) { + // handle deleted directory + const fileSet = new Set(); + for (const [dirPath, files] of this.watchedDirectoryFiles) { + if (dirPath.startsWith(event.path)) { + for (const filePath of files) { + fileSet.add(filePath); + } + } + } + + if (fileSet.size) { + for (const filePath of fileSet) { + this.getAffectedFiles(filePath, deletedFiles); + } + + for (const filePath of deletedFiles) { + this.invalidationMap.delete(filePath); + this.removeFileFromWatchedDirectory(filePath); + } } - return this.options.processFiles?.(this, affectedFiles, deletedFiles, event); } } + + if (this.options.processFiles && (affectedFiles.size || deletedFiles.size)) { + const { generatedFiles } = await this.options.processFiles( + this, + affectedFiles, + deletedFiles, + originalEvent + ); + + return { + hasChanges: true, + generatedFiles, + }; + } else { + return { + hasChanges: false, + generatedFiles: new Set(), + }; + } } - private getAffectedFiles(filePath: string, visited = new Set()): Set { + public getAffectedFiles(filePath: string, visited = new Set()): Set { if (visited.has(filePath)) { return visited; } @@ -156,7 +189,14 @@ export class DirectoryProcessService { return visited; } private watchHandler = (event: IWatchEvent) => { - this.handleWatchChange(event).catch((error) => this.options.onError?.(error)); + const files = new Map(); + files.set(event.path, event); + + for (const file of this.getAffectedFiles(event.path)) { + files.set(file, createWatchEvent(file, this.fs)); + } + + this.handleWatchChange(files, event).catch((error) => this.options.onError?.(error)); }; private filterWatchItems = (event: DirectoryItem): boolean => { const { fileFilter, directoryFilter } = this.options; @@ -168,3 +208,10 @@ export class DirectoryProcessService { return false; }; } + +export function createWatchEvent(filePath: string, fs = nodeFs): IWatchEvent { + return { + path: filePath, + stats: fs.existsSync(filePath) ? fs.statSync(filePath) : null, + }; +} diff --git a/packages/cli/src/generate-manifest.ts b/packages/cli/src/generate-manifest.ts index 25cba46ac..fa72fa686 100644 --- a/packages/cli/src/generate-manifest.ts +++ b/packages/cli/src/generate-manifest.ts @@ -6,7 +6,7 @@ import type { Log } from './logger'; export function generateManifest( rootDir: string, filesToBuild: Set, - manifestOutputPath = '', + manifestOutputPath: string, stylable: Stylable, mode: string, log: Log, @@ -15,31 +15,29 @@ export function generateManifest( function getBuildNamespace(stylable: Stylable, filePath: string): string { return stylable.fileProcessor.process(filePath).namespace; } - if (manifestOutputPath) { - const manifest = [...filesToBuild].reduce<{ - namespaceMapping: { - [key: string]: string; - }; - }>( - (manifest, filePath) => { - manifest.namespaceMapping[relative(rootDir, filePath)] = getBuildNamespace( - stylable, - filePath - ); - return manifest; - }, - { - namespaceMapping: {}, - } - ); - log(mode, 'creating manifest file: '); - tryRun( - () => ensureDirectory(dirname(manifestOutputPath), fs), - `Ensure directory for manifest: ${manifestOutputPath}` - ); - tryRun( - () => fs.writeFileSync(manifestOutputPath, JSON.stringify(manifest)), - 'Write Index File Error' - ); - } + const manifest = [...filesToBuild].reduce<{ + namespaceMapping: { + [key: string]: string; + }; + }>( + (manifest, filePath) => { + manifest.namespaceMapping[relative(rootDir, filePath)] = getBuildNamespace( + stylable, + filePath + ); + return manifest; + }, + { + namespaceMapping: {}, + } + ); + log(mode, 'creating manifest file: '); + tryRun( + () => ensureDirectory(dirname(manifestOutputPath), fs), + `Ensure directory for manifest: ${manifestOutputPath}` + ); + tryRun( + () => fs.writeFileSync(manifestOutputPath, JSON.stringify(manifest)), + 'Write Index File Error' + ); } diff --git a/packages/cli/src/handle-assets.ts b/packages/cli/src/handle-assets.ts index f5d144d16..baa91baa5 100644 --- a/packages/cli/src/handle-assets.ts +++ b/packages/cli/src/handle-assets.ts @@ -8,6 +8,8 @@ export function handleAssets( outDir: string, fs: IFileSystem ) { + const generatedAssets = new Set(); + const { dirname, join } = fs; for (const originalPath of assets) { if (fs.existsSync(originalPath)) { @@ -16,6 +18,9 @@ export function handleAssets( const targetDir = dirname(targetPath); ensureDirectory(targetDir, fs); fs.writeFileSync(targetPath, content); + generatedAssets.add(targetPath); } } + + return generatedAssets; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3b8454317..4ccf76e64 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,8 +1,22 @@ -export { BuildOptions, build, messages } from './build'; -export { Generator, ReExports, reExportsAllSymbols } from './base-generator'; -export { Configuration, STCConfig, projectsConfig } from './projects-config'; +export { build } from './build'; +export { Log, createLogger } from './logger'; +export { + IndexGenerator, + IndexGenerator as Generator, + ReExports, + reExportsAllSymbols, +} from './base-generator'; +export { + BuildOptions, + Configuration, + ConfigurationProvider, + STCConfig, + ResolveRequests, + typedConfiguration, +} from './types'; export { DirectoryProcessService, DirectoryProcessServiceOptions, } from './directory-process-service/directory-process-service'; +export { BuildStylableContext, buildStylable } from './build-stylable'; export type { CodeMod } from './code-mods/types'; diff --git a/packages/cli/src/logger.ts b/packages/cli/src/logger.ts index 10adaa19d..46660632b 100644 --- a/packages/cli/src/logger.ts +++ b/packages/cli/src/logger.ts @@ -1,19 +1,35 @@ export const levels = { debug: Symbol('debug'), info: Symbol('info'), + clear: Symbol('clear'), }; -export function createLogger(prefix: string, shouldLog: boolean) { +export function createLogger( + onLog: (level: 'info' | 'debug', ...messages: string[]) => void, + onClear: () => void +) { return function log(...messages: any[]) { + const clear = messages[messages.length - 1] === levels.clear; + if (clear) { + onClear(); + return; + } + const info = messages[messages.length - 1] === levels.info; const debug = messages[messages.length - 1] === levels.debug; if (info || debug) { messages.pop(); } - if (shouldLog || info) { - console.log(prefix, ...messages); - } + + onLog(info ? 'info' : 'debug', ...messages); }; } export type Log = (...args: [...any[]]) => void; + +export function createDefaultLogger() { + return createLogger( + (level, ...messages) => level === 'info' && console.log(...messages), + console.clear + ); +} diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts new file mode 100644 index 000000000..cfe728fab --- /dev/null +++ b/packages/cli/src/messages.ts @@ -0,0 +1,40 @@ +export const buildMessages = { + START_WATCHING() { + return 'Start watching...'; + }, + CONTINUE_WATCH() { + return `Watching files...`; + }, + FINISHED_PROCESSING(count: number, location?: string) { + return `Finished processing ${count} ${count === 1 ? 'file' : 'files'}${ + location ? ` in "${location}"` : '' + }.`; + }, + BUILD_PROCESS_INFO(location: string) { + return `Processing files of "${location}"`; + }, + BUILD_SKIPPED(identifier?: string) { + return `No stylable files found. build skipped${identifier ? ` for "${identifier}"` : ''}.`; + }, + SKIP_GENERATED_FILE(location: string) { + return `Skipping generated file build of "${location}".`; + }, + CHANGE_EVENT_TRIGGERED(location: string) { + return `Change event triggered for "${location}".`; + }, + CHANGE_DETECTED(location: string) { + return `Change detected at "${location}". Start Processing...`; + }, + WATCH_SUMMARY(changes: number, deleted: number) { + return `Processed ${changes} changes and ${deleted} deletions.`; + }, + NO_DIANGOSTICS() { + return `Found 0 diagnostics.`; + }, +}; + +export const errorMessages = { + STYLABLE_PROCESS(filePath: string) { + return `Stylable failed to process "${filePath}"`; + }, +}; diff --git a/packages/cli/src/projects-config.ts b/packages/cli/src/projects-config.ts deleted file mode 100644 index 85ef33d6d..000000000 --- a/packages/cli/src/projects-config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BuildOptions } from './build'; -import { loadStylableConfig } from '@stylable/build-tools'; -import { CliArguments, resolveCliOptions, createDefaultOptions } from './resolve-options'; -import { removeUndefined } from './helpers'; -import { resolve } from 'path'; -import { tryRun } from './build-tools'; - -export type ConfigOptions = Omit; -export type PartialConfigOptions = Partial; - -/** - * User's configuration method - * @example - * exports.stcConfig = () => ({ - * options: { - * rootDir: './src' - * } - * }) - */ -export type Configuration = () => SingleProjectConfig; - -export interface STCConfig { - [absoluteProjectRoot: string]: ConfigOptions; -} - -interface SingleProjectConfig { - options: PartialConfigOptions; -} - -export function projectsConfig(argv: CliArguments): STCConfig { - const projectRoot = resolve(argv.rootDir); - const defaultOptions = createDefaultOptions(); - const configFile = resolveConfigFile(projectRoot); - const cliOptions = resolveCliOptions(argv, defaultOptions); - const topLevelOptions = mergeProjectsConfigs(defaultOptions, configFile?.options, cliOptions); - - return { - [projectRoot]: topLevelOptions, - }; -} - -export function resolveConfigFile(context: string) { - return loadStylableConfig(context, (config) => - tryRun( - () => (isSTCConfig(config) ? config.stcConfig() : undefined), - 'Failed to evaluate "stcConfig"' - ) - ); -} - -function isSTCConfig(config: any): config is { stcConfig: Configuration } { - return typeof config === 'object' && typeof config.stcConfig === 'function'; -} - -function mergeProjectsConfigs( - ...configs: [ConfigOptions, ...(ConfigOptions | PartialConfigOptions | undefined)[]] -): ConfigOptions { - const [config, ...rest] = configs; - - return Object.assign( - config, - ...rest.map((currentConfig) => (currentConfig ? removeUndefined(currentConfig) : {})) - ); -} diff --git a/packages/cli/src/report-diagnostics.ts b/packages/cli/src/report-diagnostics.ts index 0ca2b8152..296c0d67d 100644 --- a/packages/cli/src/report-diagnostics.ts +++ b/packages/cli/src/report-diagnostics.ts @@ -1,38 +1,30 @@ import type { DiagnosticType } from '@stylable/core'; +import { levels, Log } from './logger'; export interface Diagnostic { message: string; type: DiagnosticType; + offset?: number; } export type DiagnosticMessages = Map; -function report(diagnosticsMessages: DiagnosticMessages) { - console.log('[Stylable Diagnostics]'); - for (const [filePath, diagnostics] of diagnosticsMessages.entries()) { - console.log( - `[${filePath}]\n`, - diagnostics.map(({ type, message }) => `[${type}]: ${message}`).join('\n\n') - ); - } -} - export function reportDiagnostics( + log: Log, diagnosticsMessages: DiagnosticMessages, - diagnostics?: boolean, diagnosticsMode?: string ) { - if (!diagnosticsMessages.size) { - return; + let message = '[Stylable Diagnostics]'; + for (const [filePath, diagnostics] of diagnosticsMessages.entries()) { + message += `\n[${filePath}]\n${diagnostics + .sort(({ offset: a = 0 }, { offset: b = 0 }) => a - b) + .map(({ type, message }) => `[${type}]: ${message}`) + .join('\n')}`; } - if (diagnostics) { - report(diagnosticsMessages); - } + log(message, levels.info); - if (diagnosticsMode === 'strict' && hasErrorOrWarning(diagnosticsMessages)) { - process.exitCode = 1; - } + return diagnosticsMode === 'strict' && hasErrorOrWarning(diagnosticsMessages); } function hasErrorOrWarning(diagnosticsMessages: DiagnosticMessages) { diff --git a/packages/cli/src/tsconfig.json b/packages/cli/src/tsconfig.json index 0a937bf10..933fe6790 100644 --- a/packages/cli/src/tsconfig.json +++ b/packages/cli/src/tsconfig.json @@ -8,6 +8,7 @@ { "path": "../../node/src" }, { "path": "../../module-utils/src" }, { "path": "../../code-formatter/src" }, - { "path": "../../optimizer/src" } + { "path": "../../optimizer/src" }, + { "path": "../../build-tools/src" } ] } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 000000000..3b23f117f --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,170 @@ +import type { IFileSystem } from '@file-services/types'; +import type { Stylable } from '@stylable/core'; +import type { IndexGenerator } from './base-generator'; +import type { DiagnosticsManager, DiagnosticsMode } from './diagnostics-manager'; +import type { Log } from './logger'; + +export type PartialBuildOptions = Partial; + +/** + * User's configuration object + * @example + * exports.stcConfig = { + * options: { + * rootDir: './src' + * } + * } + */ +export type Configuration

= + | SingleProjectConfig + | MultipleProjectsConfig

; + +export type ConfigurationProvider

= () => Configuration

; + +export function typedConfiguration

( + configOrConfigProvider: Configuration

| ConfigurationProvider

+) { + return configOrConfigProvider; +} + +export interface ProjectEntity { + options: BuildOptions[]; + projectRoot: string; +} +export interface RawProjectEntity { + options: BuildOptions[]; + request: string; +} + +export type STCConfig = ProjectEntity[]; +export type ResolveRequests = ( + projects: Array, + context: ResolveProjectsContext +) => Promise | STCConfig; + +export interface ResolveProjectsContext { + rootDir: string; +} + +export type Presets

= { + [key in P]: PartialBuildOptions; +}; + +export type Projects

= + | Array]> + | Record>; + +export interface SingleProjectConfig { + options: PartialBuildOptions; +} +export interface MultipleProjectsConfig

{ + options?: PartialBuildOptions; + presets?: Presets

; + projects: Projects

; + projectsOptions?: { + resolveRequests?: ResolveRequests; + }; +} + +export type ProjectEntryValues

= + | ProjectEntryValue

+ | Array>; + +export type ProjectEntryValue

= + | P + | PartialBuildOptions + | { + preset?: P; + presets?: Array

; + options: PartialBuildOptions; + }; + +export interface CliArguments { + rootDir: string; + srcDir: string | undefined; + outDir: string | undefined; + esm: boolean | undefined; + cjs: boolean | undefined; + css: boolean | undefined; + stcss: boolean | undefined; + dts: boolean | undefined; + dtsSourceMap: boolean | undefined; + useNamespaceReference: boolean | undefined; + namespaceResolver: string; + injectCSSRequest: boolean | undefined; + cssFilename: string | undefined; + cssInJs: boolean | undefined; + optimize: boolean | undefined; + minify: boolean | undefined; + indexFile: string | undefined; + manifest: boolean | undefined; + manifestFilepath: string; + customGenerator: string | undefined; + require: string[]; + log: boolean | undefined; + diagnostics: boolean | undefined; + diagnosticsMode: string | undefined; + watch: boolean; +} + +export interface BuildOptions { + /** specify where to find source files */ + srcDir: string; + /** specify where to build the target files */ + outDir: string; + /** should the build need to output manifest file */ + manifest?: string; + /** opt into build index file and specify the filepath for the generated index file */ + indexFile?: string; + /** custom cli index generator class */ + IndexGenerator?: typeof IndexGenerator; + /** output commonjs module (.js) */ + cjs?: boolean; + /** output esm module (.mjs) */ + esm?: boolean; + /** template of the css file emitted when using outputCSS */ + outputCSSNameTemplate?: string; + /** should include the css in the generated JS module */ + includeCSSInJS?: boolean; + /** should output build css for each source file */ + outputCSS?: boolean; + /** should output source .st.css file to dist */ + outputSources?: boolean; + /** should add namespace reference to the .st.css copy */ + useNamespaceReference?: boolean; + /** should inject css import in the JS module for the generated css from outputCSS */ + injectCSSRequest?: boolean; + /** should apply css optimizations */ + optimize?: boolean; + /** should minify css */ + minify?: boolean; + /** should generate .d.ts definitions for every stylesheet */ + dts?: boolean; + /** should generate .d.ts.map files for every .d.ts mapping back to the source .st.css */ + dtsSourceMap?: boolean; + /** should emit diagnostics */ + diagnostics?: boolean; + /** determine the diagnostics mode. if strict process will exit on any exception, loose will attempt to finish the process regardless of exceptions */ + diagnosticsMode?: DiagnosticsMode; +} + +export interface BuildContext { + /** build identifier */ + identifier?: string; + /** enable watch mode */ + watch?: boolean; + /** main project root directory */ + rootDir: string; + /** project root directory */ + projectRoot: string; + /** provide a custom file-system for the build */ + fs: IFileSystem; + /** provide Stylable instance */ + stylable: Stylable; + /** log function */ + log: Log; + /** output file to source files map */ + outputFiles?: Map>; + /** stores and report diagnostics */ + diagnosticsManager?: DiagnosticsManager; +} diff --git a/packages/cli/src/watch-handler.ts b/packages/cli/src/watch-handler.ts new file mode 100644 index 000000000..49663697b --- /dev/null +++ b/packages/cli/src/watch-handler.ts @@ -0,0 +1,168 @@ +import type { IFileSystem, IWatchEvent, WatchEventListener } from '@file-services/types'; +import type { Stylable, StylableResolverCache } from '@stylable/core'; +import type { BuildContext } from './types'; +import decache from 'decache'; +import { + createWatchEvent, + DirectoryProcessService, +} from './directory-process-service/directory-process-service'; +import { createDefaultLogger, levels, Log } from './logger'; +import { buildMessages } from './messages'; +import { DiagnosticsManager } from './diagnostics-manager'; + +export interface WatchHandlerOptions { + log?: Log; + resolverCache?: StylableResolverCache; + outputFiles?: BuildContext['outputFiles']; + rootDir?: string; + diagnosticsManager?: DiagnosticsManager; +} + +export interface Build { + service: DirectoryProcessService; + identifier: string; + stylable: Stylable; +} + +type File = { + generated?: boolean; +} & IWatchEvent; + +export class WatchHandler { + private builds: Build[] = []; + private resolverCache: StylableResolverCache; + private log: Log; + private diagnosticsManager: DiagnosticsManager; + private generatedFiles = new Set(); + + constructor(private fileSystem: IFileSystem, private options: WatchHandlerOptions = {}) { + this.resolverCache = this.options.resolverCache ?? new Map(); + this.log = this.options.log ?? createDefaultLogger(); + this.diagnosticsManager = + this.options.diagnosticsManager ?? new DiagnosticsManager({ log: this.log }); + } + + private listener: WatchEventListener = async (event) => { + this.log(buildMessages.CHANGE_EVENT_TRIGGERED(event.path)); + + if (this.generatedFiles.has(event.path)) { + buildMessages.SKIP_GENERATED_FILE(event.path); + return; + } + + this.invalidateCache(event.path); + + let foundChanges = false; + const files = new Map(); + + for (const { service, identifier } of this.builds) { + for (const path of service.getAffectedFiles(event.path)) { + if (files.has(path)) { + continue; + } + + files.set(path, createWatchEvent(path, this.fileSystem)); + } + + const { hasChanges, generatedFiles } = await service.handleWatchChange(files, event); + + if (hasChanges) { + if (!foundChanges) { + foundChanges = true; + this.generatedFiles.clear(); + + this.log(levels.clear); + this.log(buildMessages.CHANGE_DETECTED(event.path), levels.info); + } + + for (const generatedFile of generatedFiles) { + this.generatedFiles.add(generatedFile); + + files.set(generatedFile, { + ...(files.get(generatedFile) || + createWatchEvent(generatedFile, this.fileSystem)), + generated: true, + }); + } + + this.log(buildMessages.BUILD_PROCESS_INFO(identifier), Array.from(files.keys())); + } + } + + if (foundChanges) { + const { changed, deleted } = filesStats(files); + + this.log(buildMessages.WATCH_SUMMARY(changed, deleted), levels.info); + + const reported = this.diagnosticsManager.report(); + + if (!reported) { + this.log( + buildMessages.NO_DIANGOSTICS(), + buildMessages.CONTINUE_WATCH(), + levels.info + ); + } else { + this.log(buildMessages.CONTINUE_WATCH(), levels.info); + } + } + }; + + public register(process: Build) { + this.builds.push(process); + } + + public start() { + this.log(buildMessages.START_WATCHING(), levels.info); + this.fileSystem.watchService.addGlobalListener(this.listener); + } + + public async stop() { + this.diagnosticsManager.clear(); + this.fileSystem.watchService.removeGlobalListener(this.listener); + + for (const { service } of this.builds) { + await service.dispose(); + } + + this.builds = []; + } + + private invalidateCache(filePath: string) { + for (const [key, entity] of this.resolverCache) { + if ( + !entity.value || + entity.resolvedPath === filePath || + // deep source invalidation + this.options.outputFiles?.get(entity.resolvedPath)?.has(filePath) + ) { + if (entity.kind === 'js') { + decache(filePath); + } + + this.resolverCache.delete(key); + } + } + } +} + +function filesStats(files: Map) { + const filesChangesSummary = { + changed: 0, + deleted: 0, + }; + + for (const file of files.values()) { + if (file.generated) { + continue; + } + + if (file.stats) { + filesChangesSummary.changed++; + } else { + filesChangesSummary.deleted++; + } + } + + return filesChangesSummary; +} diff --git a/packages/cli/test/build.spec.ts b/packages/cli/test/build.spec.ts index e83b993f0..f4b9460b7 100644 --- a/packages/cli/test/build.spec.ts +++ b/packages/cli/test/build.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { Stylable, functionWarnings, processorWarnings, murmurhash3_32_gc } from '@stylable/core'; import { build } from '@stylable/cli'; import { createMemoryFs } from '@file-services/memory'; +import { DiagnosticsManager } from '@stylable/cli/dist/diagnostics-manager'; import { STImport } from '@stylable/core/dist/features'; const log = () => { @@ -30,17 +31,21 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: 'lib', - srcDir: '.', - rootDir: '/', - log, - cjs: true, - outputSources: true, - }); + await build( + { + outDir: 'lib', + srcDir: '.', + cjs: true, + outputSources: true, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); [ '/lib/main.st.css', @@ -86,18 +91,22 @@ describe('build stand alone', () => { }, }); - await build({ - extension: '.st.css', - fs, - stylable, - rootDir: '/', - srcDir: 'src', - outDir: 'cjs', - log, - cjs: true, - outputSources: true, - useNamespaceReference: true, - }); + await build( + { + srcDir: 'src', + outDir: 'cjs', + cjs: true, + outputSources: true, + useNamespaceReference: true, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); [ '/cjs/main.st.css', @@ -112,16 +121,20 @@ describe('build stand alone', () => { 'st-namespace-reference="../src/main.st.css"' ); - await build({ - extension: '.st.css', - fs, - stylable, - rootDir: '/', - srcDir: 'cjs', - outDir: 'cjs2', - log, - cjs: true, - }); + await build( + { + srcDir: 'cjs', + outDir: 'cjs2', + cjs: true, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); // check two builds using sourceNamespace are identical // compare two serializable js modules including their namespace @@ -131,6 +144,7 @@ describe('build stand alone', () => { }); it('should report errors originating from stylable (process + transform)', async () => { + const identifier = 'build-identifier'; const fs = createMemoryFs({ '/comp.st.css': ` :import { @@ -146,18 +160,26 @@ describe('build stand alone', () => { }); const stylable = new Stylable('/', fs, () => ({})); - - const { diagnosticsMessages } = await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - rootDir: '/', - log, - cjs: true, - }); - const messages = diagnosticsMessages.get('/comp.st.css')!; + const diagnosticsManager = new DiagnosticsManager(); + + await build( + { + outDir: '.', + srcDir: '.', + cjs: true, + diagnostics: true, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + diagnosticsManager, + identifier, + } + ); + const messages = diagnosticsManager.get(identifier, '/comp.st.css')!.diagnostics; expect(messages[0].message).to.contain( processorWarnings.CANNOT_RESOLVE_EXTEND('MissingComp') @@ -183,18 +205,22 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: './dist', - srcDir: '.', - rootDir: '/', - log, - cjs: true, - outputCSS: true, - outputCSSNameTemplate: '[filename].global.css', - }); + await build( + { + outDir: './dist', + srcDir: '.', + cjs: true, + outputCSS: true, + outputCSSNameTemplate: '[filename].global.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const builtFile = fs.readFileSync('/dist/comp.global.css', 'utf8'); @@ -220,19 +246,23 @@ describe('build stand alone', () => { }, }); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: './dist', - srcDir: '.', - minify: true, - rootDir: '/', - log, - cjs: true, - outputCSS: true, - outputCSSNameTemplate: '[filename].global.css', - }); + await build( + { + outDir: './dist', + srcDir: '.', + minify: true, + cjs: true, + outputCSS: true, + outputCSSNameTemplate: '[filename].global.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const builtFile = fs.readFileSync('/dist/comp.global.css', 'utf8'); @@ -250,19 +280,23 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: './dist', - srcDir: '.', - rootDir: '/', - log, - cjs: true, - outputCSS: true, - injectCSSRequest: true, - outputCSSNameTemplate: '[filename].global.css', - }); + await build( + { + outDir: './dist', + srcDir: '.', + cjs: true, + outputCSS: true, + injectCSSRequest: true, + outputCSSNameTemplate: '[filename].global.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); expect(fs.readFileSync('/dist/comp.st.css.js', 'utf8')).contains( `require("./comp.global.css")` @@ -279,17 +313,21 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - rootDir: '/', - log, - dts: true, - dtsSourceMap: false, - }); + await build( + { + outDir: '.', + srcDir: '.', + dts: true, + dtsSourceMap: false, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); ['/main.st.css', '/main.st.css.d.ts'].forEach((p) => { expect(fs.existsSync(p), p).to.equal(true); @@ -313,17 +351,21 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - rootDir: '/', - log, - dts: true, - dtsSourceMap: false, - }); + await build( + { + outDir: '.', + srcDir: '.', + dts: true, + dtsSourceMap: false, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); ['/main.st.css', '/main.st.css.d.ts'].forEach((p) => { expect(fs.existsSync(p), p).to.equal(true); @@ -361,17 +403,21 @@ describe('build stand alone', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - rootDir: '/', - log, - dts: true, - dtsSourceMap: true, - }); + await build( + { + outDir: '.', + srcDir: '.', + dts: true, + dtsSourceMap: true, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); ['/main.st.css', '/main.st.css.d.ts', '/main.st.css.d.ts.map'].forEach((p) => { expect(fs.existsSync(p), p).to.equal(true); diff --git a/packages/cli/test/cli-codemod.spec.ts b/packages/cli/test/cli-codemod.spec.ts index 8df5a3e87..6e60559f0 100644 --- a/packages/cli/test/cli-codemod.spec.ts +++ b/packages/cli/test/cli-codemod.spec.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; -import { populateDirectorySync, loadDirSync } from './test-kit/cli-test-kit'; -import { runCliCodeMod } from './test-kit/cli-test-kit'; +import { populateDirectorySync, loadDirSync, runCliCodeMod } from '@stylable/e2e-test-kit'; import type { CodeMod } from '@stylable/cli'; describe('Stylable Cli Code Mods', () => { diff --git a/packages/cli/test/cli.spec.ts b/packages/cli/test/cli.spec.ts index 066ece1ec..faff2783b 100644 --- a/packages/cli/test/cli.spec.ts +++ b/packages/cli/test/cli.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; import { evalStylableModule } from '@stylable/module-utils/dist/test/test-kit'; import { resolveNamespace } from '@stylable/node'; -import { loadDirSync, populateDirectorySync, runCliSync } from './test-kit/cli-test-kit'; +import { loadDirSync, populateDirectorySync, runCliSync } from '@stylable/e2e-test-kit'; import { processorWarnings } from '@stylable/core'; import { STImport } from '@stylable/core/dist/features'; @@ -356,5 +356,26 @@ describe('Stylable Cli', function () { stdout.match(new RegExp(STImport.diagnostics.NO_ST_IMPORT_IN_NESTED_SCOPE(), 'g')) ).to.have.length(1); }); + + it('should report error when source dir match out dir and output sources enabled', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + }); + + const res = runCliSync([ + '--rootDir', + tempDir.path, + '--outDir', + './', + '--srcDir', + './', + '-w', + '--stcss', + '--cjs=false', + ]); + expect(res.stderr).to.contain( + 'Error: Invalid configuration: When using "stcss" outDir and srcDir must be different.' + ); + }); }); }); diff --git a/packages/cli/test/code-format-cli.spec.ts b/packages/cli/test/code-format-cli.spec.ts index 00ac39e23..879a6b0de 100644 --- a/packages/cli/test/code-format-cli.spec.ts +++ b/packages/cli/test/code-format-cli.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; import { join } from 'path'; -import { loadDirSync, populateDirectorySync, runFormatCliSync } from './test-kit/cli-test-kit'; +import { loadDirSync, populateDirectorySync, runFormatCliSync } from '@stylable/e2e-test-kit'; describe('Stylable Code Format Cli', function () { let tempDir: ITempDirectory; diff --git a/packages/cli/test/codemods/st-global-custom-property-to-at-property.spec.ts b/packages/cli/test/codemods/st-global-custom-property-to-at-property.spec.ts index f4b57fe53..cb3311fb2 100644 --- a/packages/cli/test/codemods/st-global-custom-property-to-at-property.spec.ts +++ b/packages/cli/test/codemods/st-global-custom-property-to-at-property.spec.ts @@ -1,8 +1,7 @@ import { processorWarnings } from '@stylable/core'; import { expect } from 'chai'; import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; -import { populateDirectorySync, loadDirSync } from '../test-kit/cli-test-kit'; -import { runCliCodeMod } from '../test-kit/cli-test-kit'; +import { populateDirectorySync, loadDirSync, runCliCodeMod } from '@stylable/e2e-test-kit'; describe('CLI Codemods st-global-custom-property-to-at-property', () => { let tempDir: ITempDirectory; diff --git a/packages/cli/test/codemods/st-import-to-at-import.spec.ts b/packages/cli/test/codemods/st-import-to-at-import.spec.ts index c96dec433..1c1c5f71a 100644 --- a/packages/cli/test/codemods/st-import-to-at-import.spec.ts +++ b/packages/cli/test/codemods/st-import-to-at-import.spec.ts @@ -1,8 +1,7 @@ import { parseImportMessages } from '@stylable/core/dist/helpers/import'; import { expect } from 'chai'; import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; -import { populateDirectorySync, loadDirSync } from '../test-kit/cli-test-kit'; -import { runCliCodeMod } from '../test-kit/cli-test-kit'; +import { populateDirectorySync, loadDirSync, runCliCodeMod } from '@stylable/e2e-test-kit'; describe('CLI Codemods st-import-to-at-import', () => { let tempDir: ITempDirectory; diff --git a/packages/cli/test/config-options.spec.ts b/packages/cli/test/config-options.spec.ts new file mode 100644 index 000000000..3b03070dd --- /dev/null +++ b/packages/cli/test/config-options.spec.ts @@ -0,0 +1,238 @@ +import { expect } from 'chai'; +import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { loadDirSync, populateDirectorySync, runCliSync } from '@stylable/e2e-test-kit'; + +describe('Stylable CLI config file options', function () { + this.timeout(25000); + let tempDir: ITempDirectory; + + beforeEach(async () => { + tempDir = await createTempDirectory(); + }); + afterEach(async () => { + await tempDir.remove(); + }); + + it('should handle single project with configuration provdier', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': `.root{color:red}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + cjs: false, + esm: true, + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.eql([ + 'dist/style.st.css.mjs', + 'package.json', + 'stylable.config.js', + 'style.st.css', + ]); + }); + + it('should handle single project with configuration object', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': `.root{color:red}`, + 'stylable.config.js': ` + exports.stcConfig = { + options: { + outDir: './dist', + cjs: false, + esm: true, + } + } + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.eql([ + 'dist/style.st.css.mjs', + 'package.json', + 'stylable.config.js', + 'style.st.css', + ]); + }); + + it('should override config file from cli arguments', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': `.root{color:red}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ options: { + outDir: './out', + } }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path, '--outDir', './dist']); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.eql([ + 'dist/style.st.css.js', + 'package.json', + 'stylable.config.js', + 'style.st.css', + ]); + }); + + it('should get config file from specified root', () => { + populateDirectorySync(tempDir.path, { + 'my-project': { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': `.root{color:red}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ options: { + outDir: './dist', + } }) + `, + }, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', join(tempDir.path, 'my-project')]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.eql([ + 'my-project/dist/style.st.css.js', + 'my-project/package.json', + 'my-project/stylable.config.js', + 'my-project/style.st.css', + ]); + }); + + it('should override generator from config file', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'comp-A.st.css': ` + .a{} + `, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + indexFile: 'my-index.st.css', + IndexGenerator: require(${JSON.stringify( + require.resolve('./fixtures/test-generator') + )}).Generator, + outDir: './dist', + } + }) + `, + b: { + '/1-some-comp-B-.st.css': ` + .b{} + `, + }, + }); + + const { stderr, stdout } = runCliSync(['--rootDir', tempDir.path]); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + const indexFileResult = readFileSync( + join(tempDir.path, 'dist', 'my-index.st.css') + ).toString(); + + expect(indexFileResult.trim()).to.eql( + [ + ':import {-st-from: "../b/1-some-comp-B-.st.css";-st-default:Style0;}', + '.root Style0{}', + ':import {-st-from: "../comp-A.st.css";-st-default:Style1;}', + '.root Style1{}', + ].join('\n') + ); + }); + + it('should override config file generator from cli when passed customGenerator path', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'comp-A.st.css': ` + .a{} + `, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + indexFile: 'my-index.st.css', + IndexGenerator: require(${JSON.stringify( + require.resolve('./fixtures/named-exports-generator') + )}).Generator, + outDir: './dist', + } + }) + `, + b: { + '/1-some-comp-B-.st.css': ` + .b{} + `, + }, + }); + + const { stdout, stderr } = runCliSync([ + '--rootDir', + tempDir.path, + '--customGenerator', + require.resolve('./fixtures/test-generator'), + ]); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + const indexFileResult = readFileSync( + join(tempDir.path, 'dist', 'my-index.st.css') + ).toString(); + + expect(indexFileResult.trim()).to.eql( + [ + ':import {-st-from: "../b/1-some-comp-B-.st.css";-st-default:Style0;}', + '.root Style0{}', + ':import {-st-from: "../comp-A.st.css";-st-default:Style1;}', + '.root Style1{}', + ].join('\n') + ); + }); + + it('should show error message when fail to evaluate stcConfig', () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'entry.st.css': ` + .a{} + `, + 'stylable.config.js': ` + exports.stcConfig = () => { + throw new Error('Custom Error') + } + `, + }); + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(stderr).to.match(/Error: Failed to evaluate "stcConfig"/); + expect(stderr).to.match(/Custom Error/); + }); +}); diff --git a/packages/cli/test/config-presets.spec.ts b/packages/cli/test/config-presets.spec.ts new file mode 100644 index 000000000..f1b87adfe --- /dev/null +++ b/packages/cli/test/config-presets.spec.ts @@ -0,0 +1,420 @@ +import { expect } from 'chai'; +import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; +import { loadDirSync, populateDirectorySync, runCliSync } from '@stylable/e2e-test-kit'; + +describe('Stylable CLI config presets', function () { + this.timeout(25000); + let tempDir: ITempDirectory; + + beforeEach(async () => { + tempDir = await createTempDirectory(); + }); + afterEach(async () => { + await tempDir.remove(); + }); + + it('should handle single preset (object)', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'comp-lib': { + dts: true, + outputSources: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: { + 'packages/*': 'comp-lib' + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css.d.ts', + 'packages/project-a/dist/style.st.css', + ]); + }); + + it('should handle single preset (array)', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'comp-lib': { + dts: true, + outputSources: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + ['packages/*','comp-lib'] + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css.d.ts', + 'packages/project-a/dist/style.st.css', + ]); + }); + + it('should handle multiple presets (object)', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'a': { + outputSources: true, + }, + 'b': { + dts: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: { + 'packages/*': ['a', 'b'] + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css', + 'packages/project-a/dist/style.st.css.d.ts', + ]); + }); + + it('should handle multiple presets (array)', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'a': { + outputSources: true, + }, + 'b': { + dts: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + ['packages/*', ['a', 'b']] + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css', + 'packages/project-a/dist/style.st.css.d.ts', + ]); + }); + + it('should handle single preset override', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'comp-lib': { + dts: true, + outputSources: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: { + 'packages/*': { + preset: 'comp-lib', + options: { + dts: false + } + } + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css', + ]); + + expect(Object.keys(dirContent)).not.to.include.members([ + 'packages/project-a/dist/style.st.css.d.ts', + ]); + }); + + it('should handle multiple presets overrides', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'a': { + outputSources: true, + cjs: true + }, + 'b': { + esm: true, + cjs: true + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: { + 'packages/*': { + presets: ['a', 'b'], + options: { + cjs: false, + } + } + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css', + 'packages/project-a/dist/style.st.css.mjs', + ]); + + expect(Object.keys(dirContent)).not.to.include.members([ + 'packages/project-a/dist/style.st.css.js', + 'packages/project-b/dist/style.st.css.js', + ]); + }); + + it('should handle options and preset mix', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + 'a': { + outputSources: true, + } + }, + options: { + outDir: './dist', + srcDir: './src', + }, + projects: { + 'packages/*': ['a', { indexFile: './index.st.css' }] + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css', + 'packages/project-a/dist/index.st.css', + ]); + }); + + it('should throw when used non-existing preset', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + ['packages/*', 'a'] + ] + }) +`, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr, 'has cli error').to.match(/Cannot resolve preset named "a"/i); + }); +}); diff --git a/packages/cli/test/config-projects.spec.ts b/packages/cli/test/config-projects.spec.ts new file mode 100644 index 000000000..8be188ea6 --- /dev/null +++ b/packages/cli/test/config-projects.spec.ts @@ -0,0 +1,700 @@ +import { expect } from 'chai'; +import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; +import { join } from 'path'; +import { + smlinkSymbol, + loadDirSync, + populateDirectorySync, + runCliSync, +} from '@stylable/e2e-test-kit'; +import { functionWarnings } from '@stylable/core'; + +describe('Stylable CLI config multiple projects', function () { + this.timeout(25000); + let tempDir: ITempDirectory; + + beforeEach(async () => { + tempDir = await createTempDirectory(); + }); + afterEach(async () => { + await tempDir.remove(); + }); + + describe('Options resolution and overrides', () => { + it('should handle multiple projects', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + "dependencies": { + "c": "0.0.0" + } + }`, + }, + 'project-c': { + 'style.st.css': `.root{color:gold}`, + 'package.json': `{ + "name": "c", + "version": "0.0.0" + }`, + }, + 'project-b': { + 'style.st.css': `.root{color:blue}`, + 'package.json': `{ + "name": "b", + "version": "0.0.0", + "dependencies": { + "a": "0.0.0" + } + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + dts: true, + }, + projects: ['packages/*'] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/style.st.css.d.ts', + 'packages/project-b/dist/style.st.css.d.ts', + 'packages/project-c/dist/style.st.css.d.ts', + ]); + }); + + it('should handle override for specific request', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0" + }`, + }, + 'project-b': { + 'style.st.css': `.root{color:blue}`, + 'package.json': `{ + "name": "b", + "version": "0.0.0", + "dependencies": { + "a": "0.0.0" + } + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + }, + projects: [ + 'packages/*', + [ + 'packages/project-b', + { dts: true } + ], + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-b/dist/style.st.css.d.ts', + ]); + + expect(Object.keys(dirContent)).to.not.include.members([ + 'packages/project-a/dist/style.st.css.d.ts', + ]); + }); + + it('should handle multiple build outputs with different options for specific request', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + test: { + 'style.st.css': `.test {color: gold}`, + }, + src: { + 'style.st.css': ` + .root {color: blue;} + .foo {color:red;} + `, + }, + 'package.json': `{ + "name": "a", + "version": "0.0.0" + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + outputSources: true, + cjs: false, + dts: true + }, + projects: { + 'packages/*': [ + { + srcDir: 'src' + }, + { + outDir: './dist/test', + srcDir: 'test', + dts: false + } + ] + } + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + const dirContent = loadDirSync(tempDir.path); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + + expect(Object.keys(dirContent)).to.include.members([ + 'packages/project-a/dist/test/style.st.css', + 'packages/project-a/dist/style.st.css', + 'packages/project-a/dist/style.st.css.d.ts', + ]); + + expect(Object.keys(dirContent)).not.to.include.members([ + 'packages/project-a/dist/test/style.st.css.d.ts', + ]); + }); + }); + + describe('Projects request resolution and ordering', () => { + it('should handle topological watch built order', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': ` + @st-import B from "../../project-b/dist/style.st.css"; + + .root { + -st-extends: B; + color: gold; + } + + .foo {} + `, + }, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + "dependencies": { + "b": "0.0.0" + } + }`, + }, + 'project-b': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': `{ + "name": "b", + "version": "0.0.0" + }`, + }, + 'project-c': { + src: { + 'style.st.css': ` + @st-import [foo] from "../../project-a/dist/style.st.css"; + + .root { + -st-extends: foo; + color: gold; + } + `, + }, + 'package.json': `{ + "name": "c", + "version": "0.0.0", + "dependencies": { + "a": "0.0.0" + } + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + outputSources: true, + cjs: false, + }, + projects: ['packages/*'] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + }); + + it('should prioritize build order by projects specification', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0" + }`, + }, + 'prebuild-b': { + 'style.st.css': `.root{color:blue}`, + 'package.json': `{ + "name": "b", + "version": "0.0.0", + "dependencies": { + "a": "0.0.0" + } + }`, + }, + 'prebuild-c': { + 'style.st.css': `.root{color:blue}`, + 'package.json': `{ + "name": "c", + "version": "0.0.0", + "dependencies": { + "b": "0.0.0" + } + }`, + }, + 'project-d': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "d", + "version": "0.0.0", + "dependencies": { + "b": "0.0.0" + } + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + dts: true, + }, + projects: [ + 'packages/prebuild-*', + 'packages/*' + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path, '--log']); + + const projectDIndex = stdout.indexOf('project-d'); + const projectAIndex = stdout.indexOf('project-a'); + const projectCIndex = stdout.indexOf('project-c'); + const projectBIndex = stdout.indexOf('project-b'); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(projectDIndex, 'invalid build order') + .to.be.greaterThan(projectAIndex) + .and.greaterThan(projectCIndex) + .and.greaterThan(projectBIndex); + }); + + it('should resolve request from node_modules', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + node_modules: { + a: { + type: smlinkSymbol, + path: join('..', '..', 'packages', 'project-a'), + }, + b: { + type: smlinkSymbol, + path: join('..', '..', 'packages', 'project-b'), + }, + }, + packages: { + 'project-a': { + 'style.st.css': ` + @st-import B from "${join('b', 'dist', 'style.st.css')}"; + + .root { + -st-extends: B; + color: gold; + } + `, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + "dependencies": { + "b": "0.0.0" + } + }`, + }, + 'project-b': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "b", + "version": "0.0.0" + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + outputSources: true, + cjs: false, + }, + projects: ['packages/*'] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stderr, 'has cli error').not.to.match(/error/i); + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + }); + }); + + describe('Projects validation', () => { + it('should dedup diagnostics across build processes', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'mixin.js': ` + let count = 0; + module.exports = () => { + count++; + if (count === 1 || count === 4) { + return 'red'; + } else { + throw new Error('error ' + count); + } + } + `, + 'style.st.css': ` + @st-import color from './mixin.js'; + + .root { + color1: color(); + color2: color(); + x: value(unknown); + } + `, + 'package.json': `{ + "name": "a", + "version": "0.0.0" + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + }, + projects: { + 'packages/*': [{}, {}] + } + }) + `, + }); + + const { stdout } = runCliSync(['--rootDir', tempDir.path]); + + const firstError = stdout.indexOf('error 3'); + const secondError = stdout.indexOf('error 2'); + const thirdError = stdout.indexOf(functionWarnings.UNKNOWN_VAR('unknown')); + + expect(firstError, 'sorted by location') + .to.be.lessThan(secondError) + .and.lessThan(thirdError); + expect(stdout.match(functionWarnings.UNKNOWN_VAR('unknown'))?.length).to.eql(1); + }); + + it('should throw when the property "projects" is invalid', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + }, + projects: 999 + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr).to.match(new RegExp(`Error: Invalid projects type`)); + }); + + it('should throw when have duplicate request', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + "dependencies": { + "b": "0.0.0" + } + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + }, + projects: [ 'packages/*', 'packages/*'] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr).to.match( + new RegExp(`Error: Stylable CLI config can not have a duplicate project requests`) + ); + }); + + it('should throw when request does not resolve', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + 'style.st.css': `.root{color:red}`, + 'package.json': `{ + "name": "a", + "version": "0.0.0", + }`, + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + }, + projects: ['packages/project-b'] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr).to.match( + new RegExp( + `Error: Stylable CLI config can not resolve project request "packages/project-b"` + ) + ); + }); + + it('should throw when request has invalid single value', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + [ 'packages/*', 5 ], + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr, 'has cli error').to.include('Error: Cannot resolve entry "5"'); + }); + + it('should throw when request has invalid value in multiple entry values', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + [ 'packages/*', [{ dts: true }, true] ], + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr, 'has cli error').to.include('Error: Cannot resolve entry "true"'); + }); + + it('should throw when has invalid "dts" configuration in single project', () => { + populateDirectorySync(tempDir.path, { + 'package.json': JSON.stringify({ + name: 'workspace', + version: '0.0.0', + private: true, + }), + packages: { + 'project-a': { + src: { + 'style.st.css': `.root{color:red}`, + }, + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + }), + }, + }, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + }, + projects: [ + [ 'packages/*', {dts: false, dtsSourceMap: true} ], + ] + }) + `, + }); + + const { stdout, stderr } = runCliSync(['--rootDir', tempDir.path]); + + expect(stdout, 'has diagnostic error').not.to.match(/error/i); + expect(stderr, 'has cli error').to.include( + 'Error: "packages/*" options - "dtsSourceMap" requires turning on "dts"' + ); + }); + }); +}); diff --git a/packages/cli/test/config.spec.ts b/packages/cli/test/config.spec.ts deleted file mode 100644 index f24956e9b..000000000 --- a/packages/cli/test/config.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { expect } from 'chai'; -import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { loadDirSync, populateDirectorySync, runCliSync } from './test-kit/cli-test-kit'; - -describe('Stylable Cli Config', function () { - this.timeout(25000); - let tempDir: ITempDirectory; - - beforeEach(async () => { - tempDir = await createTempDirectory(); - }); - afterEach(async () => { - await tempDir.remove(); - }); - - describe('Config file', () => { - it('should handle single project', () => { - populateDirectorySync(tempDir.path, { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'style.st.css': `.root{color:red}`, - 'stylable.config.js': ` - exports.stcConfig = () => ({ - options: { - outDir: './dist', - cjs: false, - esm: true, - } - }) - `, - }); - - runCliSync(['--rootDir', tempDir.path]); - - const dirContent = loadDirSync(tempDir.path); - expect(Object.keys(dirContent)).to.eql([ - 'dist/style.st.css.mjs', - 'package.json', - 'stylable.config.js', - 'style.st.css', - ]); - }); - - it('should override config file from cli arguments', () => { - populateDirectorySync(tempDir.path, { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'style.st.css': `.root{color:red}`, - 'stylable.config.js': ` - exports.stcConfig = () => ({ options: { - outDir: './out', - } }) - `, - }); - - runCliSync(['--rootDir', tempDir.path, '--outDir', './dist']); - - const dirContent = loadDirSync(tempDir.path); - expect(Object.keys(dirContent)).to.eql([ - 'dist/style.st.css.js', - 'package.json', - 'stylable.config.js', - 'style.st.css', - ]); - }); - - it('should get config file from specified root', () => { - populateDirectorySync(tempDir.path, { - 'my-project': { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'style.st.css': `.root{color:red}`, - 'stylable.config.js': ` - exports.stcConfig = () => ({ options: { - outDir: './dist', - } }) - `, - }, - }); - - runCliSync(['--rootDir', join(tempDir.path, 'my-project')]); - - const dirContent = loadDirSync(tempDir.path); - expect(Object.keys(dirContent)).to.eql([ - 'my-project/dist/style.st.css.js', - 'my-project/package.json', - 'my-project/stylable.config.js', - 'my-project/style.st.css', - ]); - }); - - it('should override generator from config file', () => { - populateDirectorySync(tempDir.path, { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'comp-A.st.css': ` - .a{} - `, - 'stylable.config.js': ` - exports.stcConfig = () => ({ - options: { - indexFile: 'my-index.st.css', - Generator: require(${JSON.stringify( - require.resolve('./fixtures/test-generator') - )}).Generator, - outDir: './dist', - } - }) - `, - b: { - '/1-some-comp-B-.st.css': ` - .b{} - `, - }, - }); - - runCliSync(['--rootDir', tempDir.path]); - - const indexFileResult = readFileSync( - join(tempDir.path, 'dist', 'my-index.st.css') - ).toString(); - - expect(indexFileResult.trim()).to.eql( - [ - ':import {-st-from: "../b/1-some-comp-B-.st.css";-st-default:Style0;}', - '.root Style0{}', - ':import {-st-from: "../comp-A.st.css";-st-default:Style1;}', - '.root Style1{}', - ].join('\n') - ); - }); - - it('should override config file generator from cli when passed customGenerator path', () => { - populateDirectorySync(tempDir.path, { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'comp-A.st.css': ` - .a{} - `, - 'stylable.config.js': ` - exports.stcConfig = () => ({ - options: { - indexFile: 'my-index.st.css', - Generator: require(${JSON.stringify( - require.resolve('./fixtures/named-exports-generator') - )}).Generator, - outDir: './dist', - } - }) - `, - b: { - '/1-some-comp-B-.st.css': ` - .b{} - `, - }, - }); - - runCliSync([ - '--rootDir', - tempDir.path, - '--customGenerator', - require.resolve('./fixtures/test-generator'), - ]); - - const indexFileResult = readFileSync( - join(tempDir.path, 'dist', 'my-index.st.css') - ).toString(); - - expect(indexFileResult.trim()).to.eql( - [ - ':import {-st-from: "../b/1-some-comp-B-.st.css";-st-default:Style0;}', - '.root Style0{}', - ':import {-st-from: "../comp-A.st.css";-st-default:Style1;}', - '.root Style1{}', - ].join('\n') - ); - }); - - it('should give a custom error message when fail to eval stcConfig', () => { - populateDirectorySync(tempDir.path, { - 'package.json': `{"name": "test", "version": "0.0.0"}`, - 'entry.st.css': ` - .a{} - `, - 'stylable.config.js': ` - exports.stcConfig = () => { - throw new Error('Custom Error') - } - `, - }); - - const { stderr } = runCliSync(['--rootDir', tempDir.path]); - - expect(stderr).to.match(/Error: Failed to evaluate "stcConfig"/); - expect(stderr).to.match(/Custom Error/); - }); - }); -}); diff --git a/packages/cli/test/directory-process-service/directory-process-service.spec.ts b/packages/cli/test/directory-process-service/directory-process-service.spec.ts index b3a2bb799..961c94953 100644 --- a/packages/cli/test/directory-process-service/directory-process-service.spec.ts +++ b/packages/cli/test/directory-process-service/directory-process-service.spec.ts @@ -51,13 +51,20 @@ describe('DirectoryWatchService', () => { watchMode: true, fileFilter: isTemplateFile, processFiles(watcher, affectedFiles) { + const generatedFiles = new Set(); for (const filePath of affectedFiles) { const { deps, value } = evalTemplate(fs, filePath); - writeTemplateOutputToDist(fs, filePath, value); + const { outFilePath } = writeTemplateOutputToDist(fs, filePath, value); + generatedFiles.add(outFilePath); + for (const dep of deps) { watcher.registerInvalidateOnChange(dep, filePath); } } + + return { + generatedFiles, + }; }, }); @@ -105,9 +112,12 @@ describe('DirectoryWatchService', () => { watchMode: true, fileFilter: isTemplateFile, processFiles(watcher, affectedFiles, _, changeOrigin) { + const generatedFiles = new Set(); for (const filePath of affectedFiles) { const { deps, value } = evalTemplate(fs, filePath); - writeTemplateOutputToDist(fs, filePath, value); + const { outFilePath } = writeTemplateOutputToDist(fs, filePath, value); + generatedFiles.add(outFilePath); + for (const dep of deps) { watcher.registerInvalidateOnChange(dep, filePath); } @@ -116,6 +126,10 @@ describe('DirectoryWatchService', () => { changedFiles: Array.from(affectedFiles), changeOriginPath: changeOrigin?.path, }); + + return { + generatedFiles, + }; }, }); @@ -143,13 +157,19 @@ describe('DirectoryWatchService', () => { watchMode: true, fileFilter: isTemplateFile, processFiles(watcher, affectedFiles) { + const generatedFiles = new Set(); for (const filePath of affectedFiles) { const { deps, value } = evalTemplate(fs, filePath); - writeTemplateOutputToDist(fs, filePath, value); + const { outFilePath } = writeTemplateOutputToDist(fs, filePath, value); + generatedFiles.add(outFilePath); for (const dep of deps) { watcher.registerInvalidateOnChange(dep, filePath); } } + + return { + generatedFiles, + }; }, }); @@ -171,13 +191,19 @@ describe('DirectoryWatchService', () => { watchMode: true, fileFilter: isTemplateFile, processFiles(watcher, affectedFiles) { + const generatedFiles = new Set(); for (const filePath of affectedFiles) { const { deps, value } = evalTemplate(fs, filePath); - writeTemplateOutputToDist(fs, filePath, value); + const { outFilePath } = writeTemplateOutputToDist(fs, filePath, value); + generatedFiles.add(outFilePath); for (const dep of deps) { watcher.registerInvalidateOnChange(dep, filePath); } } + + return { + generatedFiles, + }; }, }); @@ -217,6 +243,10 @@ describe('DirectoryWatchService', () => { affectedFiles: Array.from(affectedFiles), changeOriginPath: changeOrigin?.path, }); + + return { + generatedFiles: new Set(), + }; }, }); @@ -248,6 +278,10 @@ describe('DirectoryWatchService', () => { watcher.registerInvalidateOnChange(depFilePath, filePath); } } + + return { + generatedFiles: new Set(), + }; }, }); @@ -277,6 +311,10 @@ describe('DirectoryWatchService', () => { affectedFiles: Array.from(affectedFiles), changeOriginPath: changeOrigin?.path, }); + + return { + generatedFiles: new Set(), + }; }, }); @@ -385,7 +423,13 @@ function isTemplateFile(filePath: string): boolean { function writeTemplateOutputToDist(fs: IFileSystem, filePath: string, value: string) { const outDir = fs.join('dist', fs.dirname(filePath)); fs.ensureDirectorySync(outDir); - fs.writeFileSync(fs.join(outDir, fs.basename(filePath, '.template.js') + '.txt'), value); + const outFilePath = fs.join(outDir, fs.basename(filePath, '.template.js') + '.txt'); + fs.writeFileSync(outFilePath, value); + + return { + outDir, + outFilePath, + }; } function expectInvalidationMap( diff --git a/packages/cli/test/generate-index.spec.ts b/packages/cli/test/generate-index.spec.ts index 6adf91400..068068fd3 100644 --- a/packages/cli/test/generate-index.spec.ts +++ b/packages/cli/test/generate-index.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { Stylable } from '@stylable/core'; import { build } from '@stylable/cli'; import { createMemoryFs } from '@file-services/memory'; +import { DiagnosticsManager } from '@stylable/cli/dist/diagnostics-manager'; const log = () => { /**/ @@ -20,16 +21,20 @@ describe('build index', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - }); + await build( + { + outDir: '.', + srcDir: '.', + indexFile: 'index.st.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/index.st.css').toString(); @@ -54,16 +59,20 @@ describe('build index', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - }); + await build( + { + outDir: '.', + srcDir: '.', + indexFile: 'index.st.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/index.st.css').toString(); @@ -88,17 +97,21 @@ describe('build index', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - Generator: require('./fixtures/test-generator').Generator, - }); + await build( + { + outDir: '.', + srcDir: '.', + indexFile: 'index.st.css', + IndexGenerator: require('./fixtures/test-generator').Generator, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/index.st.css').toString(); @@ -112,6 +125,49 @@ describe('build index', () => { ); }); + it('should create index file when srcDir is parent directory of outDir', async () => { + const fs = createMemoryFs({ + dist: { + 'c/compA.st.css': ` + .a{} + `, + '/a/b/comp-B.st.css': ` + .b{} + `, + }, + }); + + const stylable = new Stylable('/', fs, () => ({})); + + await build( + { + outDir: '.', + srcDir: './dist', + indexFile: 'index.st.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); + + expect(fs.existsSync('/index.st.css'), 'index is not generated').to.eql(true); + + const res = fs.readFileSync('/index.st.css').toString(); + + expect(res.trim()).to.equal( + [ + ':import {-st-from: "./dist/c/compA.st.css";-st-default:CompA;}', + '.root CompA{}', + ':import {-st-from: "./dist/a/b/comp-B.st.css";-st-default:CompB;}', + '.root CompB{}', + ].join('\n') + ); + }); + it('custom generator is able to filter files from the index', async () => { const fs = createMemoryFs({ '/comp-A.st.css': ` @@ -124,17 +180,21 @@ describe('build index', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - Generator: require('./fixtures/test-generator').Generator, - }); + await build( + { + outDir: '.', + srcDir: '.', + indexFile: 'index.st.css', + IndexGenerator: require('./fixtures/test-generator').Generator, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/index.st.css').toString(); @@ -161,17 +221,21 @@ describe('build index', () => { const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: '.', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - Generator: require('./fixtures/named-exports-generator').Generator, - }); + await build( + { + outDir: '.', + srcDir: '.', + indexFile: 'index.st.css', + IndexGenerator: require('./fixtures/named-exports-generator').Generator, + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/index.st.css').toString(); @@ -196,16 +260,20 @@ describe('build index', () => { }); const stylable = new Stylable('/', fs, () => ({})); - await build({ - extension: '.st.css', - fs, - stylable, - outDir: './some-dir/other-dir/', - srcDir: '.', - indexFile: 'index.st.css', - rootDir: '/', - log, - }); + await build( + { + outDir: './some-dir/other-dir/', + srcDir: '.', + indexFile: 'index.st.css', + }, + { + fs, + stylable, + rootDir: '/', + projectRoot: '/', + log, + } + ); const res = fs.readFileSync('/some-dir/other-dir/index.st.css').toString(); @@ -222,23 +290,28 @@ describe('build index', () => { .b{} `, }); - let cliError: unknown; + const stylable = new Stylable('/', fs, () => ({})); - try { - await build({ - extension: '.st.css', - fs, - stylable, + const diagnosticsManager = new DiagnosticsManager(); + + await build( + { outDir: '.', srcDir: '.', indexFile: 'index.st.css', + diagnostics: true, + }, + { + fs, + stylable, rootDir: '/', + projectRoot: '/', log, - }); - } catch (error) { - cliError = error; - } - expect((cliError as Error)?.message).to.equal( + diagnosticsManager, + } + ); + + expect(diagnosticsManager.get('/', '/a/comp.st.css')?.diagnostics[0].message).to.equal( `Name Collision Error:\nexport symbol Comp from ${'/a/comp.st.css'} is already used by ${'/comp.st.css'}` ); }); diff --git a/packages/cli/test/test-kit/cli-test-kit.ts b/packages/cli/test/test-kit/cli-test-kit.ts deleted file mode 100644 index 4018cc405..000000000 --- a/packages/cli/test/test-kit/cli-test-kit.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { readdirSync, readFileSync, statSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { fork, spawnSync, ChildProcess } from 'child_process'; -import { on } from 'events'; -import { join, relative } from 'path'; -import type { Readable } from 'stream'; - -export function createCliTester() { - const cliProcesses: ChildProcess[] = []; - - async function processCliOutput({ - dirPath, - args, - steps, - }: { - dirPath: string; - args: string[]; - steps: Array<{ msg: string; action?: () => void }>; - }) { - const cliProcess = runCli(['--rootDir', dirPath, '--log', ...args], dirPath); - cliProcesses.push(cliProcess); - const found = []; - if (!cliProcess.stdout) { - throw new Error('no stdout on cli process'); - } - for await (const line of readLines(cliProcess.stdout)) { - const step = steps[found.length]; - - if (line.includes(step.msg)) { - found.push(true); - - if (step.action) { - step.action(); - } - - if (steps.length === found.length) { - return; - } - } - } - } - - return { - run: processCliOutput, - cleanup() { - for (const cliProcess of cliProcesses) { - cliProcess.kill(); - } - cliProcesses.length = 0; - }, - }; -} - -async function* readLines(readable: Readable) { - let buffer = ''; - for await (const e of on(readable, 'data')) { - for (const char of e.toString()) { - if (char === '\n') { - yield buffer; - buffer = ''; - } else { - buffer += char; - } - } - } - yield buffer; -} - -export function writeToExistingFile(filePath: string, content: string) { - if (existsSync(filePath)) { - writeFileSync(filePath, content); - } else { - throw new Error(`file ${filePath} does not exist`); - } -} - -const stcPath = require.resolve('@stylable/cli/bin/stc.js'); -const formatPath = require.resolve('@stylable/cli/bin/stc-format.js'); -const codeModPath = require.resolve('@stylable/cli/bin/stc-codemod.js'); - -export function runCli(cliArgs: string[] = [], cwd: string) { - return fork(stcPath, cliArgs, { cwd, stdio: 'pipe' }); -} - -export function runCliSync(cliArgs: string[] = []) { - return spawnSync('node', [stcPath, ...cliArgs], { encoding: 'utf8' }); -} - -export function runFormatCliSync(cliArgs: string[] = []) { - return spawnSync('node', [formatPath, ...cliArgs], { encoding: 'utf8' }); -} - -export function runCliCodeMod(cliArgs: string[] = []) { - return spawnSync('node', [codeModPath, ...cliArgs], { encoding: 'utf8' }); -} - -export interface Files { - [filepath: string]: string; -} - -export interface FilesStructure { - [filepath: string]: string | FilesStructure; -} - -export function loadDirSync(rootPath: string, dirPath: string = rootPath): Files { - return readdirSync(dirPath).reduce((acc, entry) => { - const fullPath = join(dirPath, entry); - const key = relative(rootPath, fullPath).replace(/\\/g, '/'); - const stat = statSync(fullPath); - if (stat.isFile()) { - acc[key] = readFileSync(fullPath, 'utf8'); - } else if (stat.isDirectory()) { - return { - ...acc, - ...loadDirSync(rootPath, fullPath), - }; - } else { - throw new Error('Not Implemented'); - } - return acc; - }, {}); -} - -export function populateDirectorySync(rootDir: string, files: FilesStructure) { - for (const [filePath, content] of Object.entries(files)) { - if (typeof content === 'object') { - const dirPath = join(rootDir, filePath); - mkdirSync(dirPath); - populateDirectorySync(dirPath, content); - } else { - writeFileSync(join(rootDir, filePath), content); - } - } -} diff --git a/packages/cli/test/watch-multiple-projects.spec.ts b/packages/cli/test/watch-multiple-projects.spec.ts new file mode 100644 index 000000000..bfe701434 --- /dev/null +++ b/packages/cli/test/watch-multiple-projects.spec.ts @@ -0,0 +1,530 @@ +import { buildMessages } from '@stylable/cli/dist/messages'; +import { STImport } from '@stylable/core/dist/features'; +import { + createCliTester, + loadDirSync, + populateDirectorySync, + writeToExistingFile, +} from '@stylable/e2e-test-kit'; +import { expect } from 'chai'; +import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; +import { realpathSync, writeFileSync } from 'fs'; +import { join, sep } from 'path'; + +describe('Stylable Cli Watch - Multiple projects', () => { + let tempDir: ITempDirectory; + const { run, cleanup } = createCliTester(); + beforeEach(async () => { + tempDir = await createTempDirectory(); + // This is used to make the output paths matching consistent since we use the real path in the logs of the CLI + tempDir.path = realpathSync(tempDir.path); + }); + afterEach(async () => { + cleanup(); + await tempDir.remove(); + }); + + it('simple watch mode on one project', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + outputSources: true + }, + projects: ['packages/*'] + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ name: 'a', version: '0.0.0' }), + 'style.st.css': ` + .root{ color:red; } + `, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-a', 'style.st.css'), + '.root{ color:yellow; }' + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 1, + join(tempDir.path, 'packages', 'project-a') + ), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files).to.contain({ + 'packages/project-a/dist/style.st.css': '.root{ color:yellow; }', + }); + }); + + it('should re-build derived files deep in the relevent package only', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + srcDir: './src', + outputCSS: true, + outputSources: true + }, + projects: ['packages/*'] + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + dependencies: { b: '0.0.0' }, + }), + src: { + 'style.st.css': ` + @st-import [color] from "../../project-b/dist/depend.st.css"; + .root{ color:value(color); } + `, + }, + }, + 'project-b': { + 'package.json': JSON.stringify({ + name: 'b', + version: '0.0.0', + }), + src: { + 'depend.st.css': ` + :vars { + color: red; + } + `, + }, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, './packages/project-b/src/depend.st.css'), + `:vars { + color: blue; + }` + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING(1, sep + join('packages', 'project-b')), + }, + { + msg: buildMessages.FINISHED_PROCESSING(1, sep + join('packages', 'project-a')), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files['packages/project-a/dist/style.css']).to.include('color:blue'); + expect( + Object.keys(files).some( + (fileName) => fileName.includes('project-b') && fileName.includes('style.st.css') + ), + 'have files from package "a" inside package "b"' + ).to.eql(false); + expect( + Object.keys(files).some( + (fileName) => fileName.includes('project-a') && fileName.includes('depend.st.css') + ), + 'have files from package "b" inside package "a"' + ).to.eql(false); + }); + + it('should re-build index files', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => { + return { + options: { + outputSources: true, + cjs: false, + outDir: './dist', + outputCSS: true, + }, + projects: [ + 'packages/*', + [ + 'packages/project-b', + { + indexFile: './index.st.css', + }, + ], + ], + }; + }; + `, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + dependencies: { b: '0.0.0' }, + }), + 'style.st.css': ` + @st-import [Foo] from "../project-b/dist/index.st.css"; + .a { + -st-extends: Foo; + } + + .a::foo { + color: red + } + `, + }, + 'project-b': { + 'package.json': JSON.stringify({ + name: 'b', + version: '0.0.0', + }), + 'foo.st.css': ` + .foo {} + `, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeFileSync( + join(tempDir.path, 'packages', 'project-a', 'style.st.css'), + ` + @st-import [Foo] from "../project-b/dist/index.st.css"; + .a { + -st-extends: Foo; + } + + .a::foo {color: red;} + + .a::bar {color: blue;} + ` + ); + }, + }, + { + msg: buildMessages.CHANGE_DETECTED( + join(tempDir.path, 'packages', 'project-a', 'style.st.css') + ), + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-b', 'foo.st.css'), + ` + .foo {} + .bar {} + ` + ); + }, + }, + { + msg: buildMessages.CHANGE_DETECTED( + join(tempDir.path, 'packages', 'project-b', 'foo.st.css') + ), + }, + { + msg: buildMessages.CHANGE_EVENT_TRIGGERED( + join(tempDir.path, 'packages', 'project-b', 'dist', 'index.st.css') + ), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files['packages/project-a/dist/style.css']).to.match( + /foo[0-9]+__foo {color: red;}/g + ); + expect(files['packages/project-a/dist/style.css']).to.match( + /foo[0-9]+__bar {color: blue;}/g + ); + }); + + it('should trigger build when changing js mixin', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + outputCSS: true, + outputSources: true + }, + projects: [ + 'packages/*', + ['packages/project-a', { + srcDir: './src', + } + ] + ] + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ + name: 'a', + version: '0.0.0', + dependencies: { b: '0.0.0' }, + }), + src: { + 'style.st.css': ` + @st-import [color] from "../../project-b/mixin"; + .root{ color:value(color); } + `, + }, + }, + 'project-b': { + 'package.json': JSON.stringify({ + name: 'b', + version: '0.0.0', + }), + 'mixin.js': ` + module.exports = { + color: 'red' + } + `, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, './packages/project-b/mixin.js'), + `module.exports = { + color: 'blue' + }` + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING(1, sep + join('packages', 'project-a')), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files['packages/project-a/dist/style.css']).to.include('color:blue'); + }); + + it('should report error on watch mode', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + outDir: './dist', + outputSources: true, + }, + projects: ['packages/*'] + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ name: 'a', version: '0.0.0' }), + 'style.st.css': ` + .root{ color:red; } + `, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-a', 'style.st.css'), + '.root{ color:yellow; {} }' + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 1, + join(tempDir.path, 'packages', 'project-a') + ), + }, + { + msg: '[error]: nesting of rules within rules is not supported', + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-a', 'style.st.css'), + '.root{ color:blue; }' + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 1, + join(tempDir.path, 'packages', 'project-a') + ), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files).to.contain({ + 'packages/project-a/dist/style.st.css': '.root{ color:blue; }', + }); + }); + + it('should keep diagnostics when a project depends on another project output', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + presets: { + pkg: { + srcDir: './src', + outDir: './dist', + outputSources: true, + }, + index: { + srcDir: './dist', + indexFile: './index.st.css', + } + }, + options: { + cjs: false, + }, + projects: { + 'packages/project-a': ['pkg', 'index'] + } + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ name: 'a', version: '0.0.0' }), + src: { + 'style.st.css': ` + .root{ color:red; } + `, + }, + }, + }, + }); + + await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-a', 'src', 'style.st.css'), + ` + @st-import Module from './does-not-exist.st.css'; + + .root{ -st-extends: Module; color:blue; } + ` + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 1, + `[1] ${sep + join('packages', 'project-a')}` + ), + }, + { + msg: STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css'), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files['packages/project-a/dist/style.st.css']).to.include('color:blue;'); + }); + + it('should not have duplicate diagnostics between shared dependency', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + srcDir: './src', + outDir: './dist', + outputSources: true, + }, + projects: ['packages/*'] + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ name: 'a', version: '0.0.0' }), + src: { + 'style.st.css': ` + @st-import Module from './does-not-exist.st.css'; + + .root{ -st-extends: Module; color:blue; } + `, + }, + }, + 'project-b': { + 'package.json': JSON.stringify({ + name: 'b', + version: '0.0.0', + }), + src: { + 'style.st.css': ` + @st-import Amodule from '../../project-a/dist/style.st.css'; + + .root{ -st-extends: Amodule ; color:red; } + `, + }, + }, + }, + }); + + const { output } = await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css'), + }, + { + msg: buildMessages.START_WATCHING(), + }, + ], + }); + + expect( + output().match(STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css')) + ).to.lengthOf(1); + }); +}); diff --git a/packages/cli/test/cli-watch.spec.ts b/packages/cli/test/watch-single-project.spec.ts similarity index 51% rename from packages/cli/test/cli-watch.spec.ts rename to packages/cli/test/watch-single-project.spec.ts index f21f1340e..b3b23bca7 100644 --- a/packages/cli/test/cli-watch.spec.ts +++ b/packages/cli/test/watch-single-project.spec.ts @@ -1,21 +1,24 @@ -import { writeFileSync, unlinkSync, rmdirSync, renameSync } from 'fs'; -import { join } from 'path'; -import { expect } from 'chai'; -import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; -import { messages } from '@stylable/cli'; +import { errorMessages, buildMessages } from '@stylable/cli/dist/messages'; +import { STImport } from '@stylable/core/dist/features'; import { createCliTester, + escapeRegExp, loadDirSync, populateDirectorySync, - runCliSync, writeToExistingFile, -} from './test-kit/cli-test-kit'; +} from '@stylable/e2e-test-kit'; +import { expect } from 'chai'; +import { createTempDirectory, ITempDirectory } from 'create-temp-directory'; +import { realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; +import { join, sep } from 'path'; -describe('Stylable Cli Watch', () => { +describe('Stylable Cli Watch - Single project', () => { let tempDir: ITempDirectory; const { run, cleanup } = createCliTester(); beforeEach(async () => { tempDir = await createTempDirectory(); + // This is used to make the output paths matching consistent since we use the real path in the logs of the CLI + tempDir.path = realpathSync(tempDir.path); }); afterEach(async () => { cleanup(); @@ -33,12 +36,13 @@ describe('Stylable Cli Watch', () => { .root{ color:green; } `, }); + await run({ dirPath: tempDir.path, args: ['--outDir', './dist', '-w', '--cjs=false', '--stcss'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { writeToExistingFile( join(tempDir.path, 'depend.st.css'), @@ -47,7 +51,7 @@ describe('Stylable Cli Watch', () => { }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(2), }, ], }); @@ -79,7 +83,7 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs=false', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { writeToExistingFile( join(tempDir.path, 'deep.st.css'), @@ -88,7 +92,7 @@ describe('Stylable Cli Watch', () => { }, }, { - msg: messages.FINISHED_PROCESSING + ' 3', + msg: buildMessages.FINISHED_PROCESSING(3), }, ], }); @@ -106,13 +110,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs=false', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { writeFileSync(join(tempDir.path, 'style.st.css'), `.root{ color:green }`); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -138,13 +142,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs=false', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { writeFileSync(join(tempDir.path, 'asset.svg'), getSvgContent(NEW_SIZE)); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -164,13 +168,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { unlinkSync(join(tempDir.path, 'style.st.css')); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -191,13 +195,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { rmdirSync(join(tempDir.path, 'styles'), { recursive: true }); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -220,13 +224,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { rmdirSync(join(tempDir.path, 'styles'), { recursive: true }); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -247,7 +251,7 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--cjs', '--css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { renameSync( join(tempDir.path, 'style.st.css'), @@ -256,10 +260,12 @@ describe('Stylable Cli Watch', () => { }, }, { - msg: messages.FINISHED_PROCESSING, + // Deleted + msg: buildMessages.FINISHED_PROCESSING(1), }, { - msg: messages.FINISHED_PROCESSING, + // Created + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -271,51 +277,78 @@ describe('Stylable Cli Watch', () => { }); }); - // it.only('should handle renames of folders', async () => { - // populateDirectorySync(tempDir.path, { - // 'package.json': `{"name": "test", "version": "0.0.0"}`, - // styles: { - // deep: { 'style.st.css': `.root{ color: red }` }, - // }, - // }); - - // await run({ - // dirPath: tempDir.path, - // args: ['--outDir', './dist', '-w', '--cjs', '--css'], - // steps: [ - // { - // msg: messages.START_WATCHING, - // action() { - // renameSync( - // join(tempDir.path, 'styles'), - // join(tempDir.path, 'styles-renamed') - // ); - // return true; - // }, - // }, - // { - // msg: messages.FINISHED_PROCESSING, - // action() { - // return true; - // }, - // }, - // { - // msg: messages.FINISHED_PROCESSING, - // action() { - // return false; - // }, - // }, - // ], - // }); - // const files = loadDirSync(tempDir.path); - // expect(files['dist/style-renamed.css']).to.include(`color: red`); - // expect(files).to.include({ - // 'package.json': '{"name": "test", "version": "0.0.0"}', - // 'style-renamed.st.css': '.root{ color: red }', - // }); - // }); - - // it('should ignore source files in dist'); + it('should report diagnostics on initial build and then start watching', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': ` + @st-import Module from './does-not-exist.st.css'; + + .root{ color: red } + `, + }); + + await run({ + dirPath: tempDir.path, + args: ['--outDir', './dist', '-w', '--cjs', '--css'], + steps: [ + { + msg: STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css'), + }, + { + msg: buildMessages.START_WATCHING(), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(Object.keys(files)).to.eql([ + 'dist/style.css', + 'dist/style.st.css.js', + 'package.json', + 'style.st.css', + ]); + }); + + it('should keep diagnostics when not fixed in second iteration', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': ` + @st-import Module from './does-not-exist.st.css'; + + .root{ color: red; } + `, + }); + + await run({ + dirPath: tempDir.path, + args: ['--outDir', './dist', '-w', '--cjs', '--css'], + steps: [ + { + msg: STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css'), + }, + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, 'style.st.css'), + ` + /* The import stays so it should get reported again */ + @st-import Module from './does-not-exist.st.css'; + + .root{ color: blue; } + ` + ); + }, + }, + { + msg: STImport.diagnostics.UNKNOWN_IMPORTED_FILE('./does-not-exist.st.css'), + }, + ], + }); + + const files = loadDirSync(tempDir.path); + expect(files['style.st.css']).to.include('.root{ color: blue; }'); + }); it('should re-build indexes', async () => { populateDirectorySync(tempDir.path, { @@ -327,13 +360,13 @@ describe('Stylable Cli Watch', () => { args: ['--outDir', './dist', '-w', '--indexFile', 'index.st.css'], steps: [ { - msg: messages.START_WATCHING, + msg: buildMessages.START_WATCHING(), action() { writeFileSync(join(tempDir.path, 'style.st.css'), `.root{ color:green }`); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), action() { writeToExistingFile( join(tempDir.path, 'style.st.css'), @@ -342,13 +375,13 @@ describe('Stylable Cli Watch', () => { }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), action() { writeFileSync(join(tempDir.path, 'comp.st.css'), `.root{ color:green }`); }, }, { - msg: messages.FINISHED_PROCESSING, + msg: buildMessages.FINISHED_PROCESSING(1), }, ], }); @@ -357,24 +390,129 @@ describe('Stylable Cli Watch', () => { expect(files['dist/index.st.css']).to.include('comp.st.css'); }); - it('should error when source dir match out dir and output sources enabled', () => { + it('should not trigger circular assets build', async () => { populateDirectorySync(tempDir.path, { 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'stylable.config.js': ` + exports.stcConfig = () => ({ + options: { + cjs: false, + }, + projects: { + 'packages/*': [ + { + outDir: './dist', + srcDir: './src', + outputSources: true + }, + { + srcDir: './src', + outDir: './src', + dts: true + } + ] + } + })`, + packages: { + 'project-a': { + 'package.json': JSON.stringify({ name: 'a', version: '0.0.0' }), + src: { + 'icon.svg': ` + + `, + 'style.st.css': ` + .root{ + color:red; + background: url('./icon.svg') + } + `, + }, + }, + }, }); - const res = runCliSync([ - '--rootDir', - tempDir.path, - '--outDir', - './', - '--srcDir', - './', - '-w', - '--stcss', - '--cjs=false', - ]); - expect(res.stderr).to.contain( - 'Error: Invalid configuration: When using "stcss" outDir and srcDir must be different.' - ); + const { output } = await run({ + dirPath: tempDir.path, + args: ['-w'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile( + join(tempDir.path, 'packages', 'project-a', 'src', 'style.st.css'), + `.root{ + color:red; + background: url('./icon.svg') + }` + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 2, + `[0] ${sep}` + join('packages', 'project-a') + ), + }, + { + msg: buildMessages.FINISHED_PROCESSING( + 2, + `[1] ${sep}` + join('packages', 'project-a') + ), + action() { + return { + sleep: 2000, + }; + }, + }, + ], + }); + + expect( + output().match( + new RegExp( + escapeRegExp( + buildMessages.CHANGE_EVENT_TRIGGERED( + join(tempDir.path, 'packages', 'project-a', 'src', 'icon.svg') + ) + ), + 'ig' + ) + )?.length, + 'svg file should trigger change event once' + ).to.eql(2, output()); + }); + + it('should keep watching when getting stylable process error', async () => { + populateDirectorySync(tempDir.path, { + 'package.json': `{"name": "test", "version": "0.0.0"}`, + 'style.st.css': `.root{ color:red }`, + }); + + await run({ + dirPath: tempDir.path, + args: ['--outDir', './dist', '-w', '--stcss'], + steps: [ + { + msg: buildMessages.START_WATCHING(), + action() { + writeToExistingFile(join(tempDir.path, 'style.st.css'), `.root;{}`); + }, + }, + { + msg: errorMessages.STYLABLE_PROCESS(join(tempDir.path, 'style.st.css')), + action() { + writeToExistingFile( + join(tempDir.path, 'style.st.css'), + `.root{ color:green }` + ); + }, + }, + { + msg: buildMessages.FINISHED_PROCESSING(1), + }, + ], + }); + const files = loadDirSync(tempDir.path); + expect(files['dist/style.st.css']).to.include('color:green'); }); }); diff --git a/packages/core/src/visit-meta-css-dependencies.ts b/packages/core/src/visit-meta-css-dependencies.ts index 5b10665af..62af901a7 100644 --- a/packages/core/src/visit-meta-css-dependencies.ts +++ b/packages/core/src/visit-meta-css-dependencies.ts @@ -5,7 +5,8 @@ import type { StylableResolver } from './stylable-resolver'; export function visitMetaCSSDependenciesBFS( meta: StylableMeta, onMetaDependency: (meta: StylableMeta, imported: Imported, depth: number) => void, - resolver: StylableResolver + resolver: StylableResolver, + onJsDependency?: (resolvedPath: string, imported: Imported) => void ): void { const visited = new Set([meta.source]); const q = [[...meta.getImportStatements()]]; @@ -19,10 +20,18 @@ export function visitMetaCSSDependenciesBFS( while (++index < items.length) { const imported = items[index]; const res = resolver.resolveImported(imported, ''); + if (res?._kind === 'css' && !visited.has(res.meta.source)) { visited.add(res.meta.source); onMetaDependency(res.meta, imported, depth + 1); q[depth + 1].push(...res.meta.getImportStatements()); + } else if (res?._kind === 'js' && onJsDependency) { + const resolvedPath = resolver.resolvePath(imported.context, imported.request); + + if (!visited.has(resolvedPath)) { + visited.add(resolvedPath); + onJsDependency(resolvedPath, imported); + } } } } diff --git a/packages/core/test/stylable-utils.spec.ts b/packages/core/test/stylable-utils.spec.ts index e71121543..7dc8bd6a7 100644 --- a/packages/core/test/stylable-utils.spec.ts +++ b/packages/core/test/stylable-utils.spec.ts @@ -82,7 +82,14 @@ describe('visitMetaCSSDependenciesBFS', () => { -st-default: D2; } - .root {} + :import { + -st-from: "./mixin"; + -st-named: color; + } + + .root { + color: value(color) + } `, }, '/d1.1.st.css': { @@ -93,7 +100,21 @@ describe('visitMetaCSSDependenciesBFS', () => { -st-default: D2; } - .root {} + :import { + -st-from: "./mixin"; + -st-named: color; + } + + .root { + color: value(color) + } + `, + }, + '/mixin.js': { + content: ` + module.exports = { + color: 'red' + } `, }, '/d2.st.css': { @@ -113,13 +134,17 @@ describe('visitMetaCSSDependenciesBFS', () => { ({ source }, _, depth) => { items.push({ source, depth }); }, - resolver + resolver, + (source) => { + items.push({ source, depth: -1 }); + } ); expect(items).to.eql([ { source: '/d1.st.css', depth: 1 }, { source: '/d1.1.st.css', depth: 1 }, { source: '/d2.st.css', depth: 2 }, + { source: '/mixin.js', depth: -1 }, ]); }); }); diff --git a/packages/e2e-test-kit/src/cli-test-kit.ts b/packages/e2e-test-kit/src/cli-test-kit.ts new file mode 100644 index 000000000..2ed91b4ed --- /dev/null +++ b/packages/e2e-test-kit/src/cli-test-kit.ts @@ -0,0 +1,231 @@ +import { + readdirSync, + readFileSync, + statSync, + writeFileSync, + existsSync, + mkdirSync, + symlinkSync, +} from 'fs'; +import { fork, spawnSync, ChildProcess } from 'child_process'; +import { on } from 'events'; +import { join, relative } from 'path'; +import type { Readable } from 'stream'; + +interface Step { + msg: string; + action?: () => void | { + sleep?: number; + }; +} + +interface ProcessCliOutputParams { + dirPath: string; + args: string[]; + steps: Step[]; + timeout?: number; +} + +export function createCliTester() { + const processes: ChildProcess[] = []; + + async function processCliOutput({ + dirPath, + args, + steps, + timeout = Number(process.env.CLI_WATCH_TEST_TIMEOUT) || 10_000, + }: ProcessCliOutputParams): Promise<{ output(): string }> { + const process = runCli(['--rootDir', dirPath, '--log', ...args], dirPath); + const lines: string[] = []; + const output = () => lines.join('\n'); + + processes.push(process); + + if (!process.stdout) { + throw new Error('no stdout on cli process'); + } + + // save the output lines to not depened on the reabline async await. + process.stdout.on('data', (e) => lines.push(e.toString())); + + const found: { message: string; time: number }[] = []; + const startTime = Date.now(); + + return Promise.race([ + onTimeout(timeout, () => new Error(`${JSON.stringify(found, null, 3)}\n\n${output()}`)), + runSteps(), + ]); + + function onTimeout(ms: number, rejectWith?: () => unknown) { + return new Promise<{ output(): string }>((resolve, reject) => + setTimeout(() => (rejectWith ? reject(rejectWith()) : resolve({ output })), ms) + ); + } + + async function runSteps() { + for await (const line of readLines(process.stdout!)) { + const step = steps[found.length]; + + if (line.includes(step.msg)) { + found.push({ + message: step.msg, + time: Date.now() - startTime, + }); + + if (step.action) { + const { sleep } = step.action() || {}; + + if (typeof sleep === 'number') { + await onTimeout(sleep); + } + } + + if (steps.length === found.length) { + return { output }; + } + } + } + return { output }; + } + } + + return { + run: processCliOutput, + cleanup() { + for (const process of processes) { + process.kill(); + } + + processes.length = 0; + }, + }; +} + +async function* readLines(readable: Readable) { + let buffer = ''; + for await (const e of on(readable, 'data')) { + for (const char of e.toString()) { + if (char === '\n') { + yield buffer; + buffer = ''; + } else { + buffer += char; + } + } + } + yield buffer; +} + +export function writeToExistingFile(filePath: string, content: string) { + if (existsSync(filePath)) { + writeFileSync(filePath, content); + } else { + throw new Error(`file ${filePath} does not exist`); + } +} + +const stcPath = require.resolve('@stylable/cli/bin/stc.js'); +const formatPath = require.resolve('@stylable/cli/bin/stc-format.js'); +const codeModPath = require.resolve('@stylable/cli/bin/stc-codemod.js'); + +export function runCli(cliArgs: string[] = [], cwd: string) { + return fork(stcPath, cliArgs, { cwd, stdio: 'pipe' }); +} + +export function runCliSync(cliArgs: string[] = []) { + return spawnSync('node', [stcPath, ...cliArgs], { encoding: 'utf8' }); +} + +export function runFormatCliSync(cliArgs: string[] = []) { + return spawnSync('node', [formatPath, ...cliArgs], { encoding: 'utf8' }); +} + +export function runCliCodeMod(cliArgs: string[] = []) { + return spawnSync('node', [codeModPath, ...cliArgs], { encoding: 'utf8' }); +} + +export interface Files { + [filepath: string]: string; +} + +export const smlinkSymbol = Symbol('smlink'); + +export interface LinkedDirectory { + type: typeof smlinkSymbol; + path: string; +} +export interface FilesStructure { + [filepath: string]: string | FilesStructure | LinkedDirectory; +} + +export function loadDirSync(rootPath: string, dirPath: string = rootPath): Files { + return readdirSync(dirPath).reduce((acc, entry) => { + const fullPath = join(dirPath, entry); + const key = relative(rootPath, fullPath).replace(/\\/g, '/'); + const stat = statSync(fullPath); + if (stat.isFile()) { + acc[key] = readFileSync(fullPath, 'utf8'); + } else if (stat.isDirectory()) { + return { + ...acc, + ...loadDirSync(rootPath, fullPath), + }; + } else { + throw new Error('Not Implemented'); + } + return acc; + }, {}); +} + +export function populateDirectorySync( + rootDir: string, + files: FilesStructure, + context: { smlinks: Map> } = { smlinks: new Map() } +) { + for (const [filePath, content] of Object.entries(files)) { + const path = join(rootDir, filePath); + + if (typeof content === 'object') { + if (content.type === smlinkSymbol) { + const existingPath = join(path, content.path as string); + try { + symlinkSync(existingPath, path); + } catch { + // The existing path does not exist yet so we save it in the context to create it later. + + if (!context.smlinks.has(existingPath)) { + context.smlinks.set(existingPath, new Set()); + } + + context.smlinks.get(existingPath)!.add(path); + } + } else { + mkdirSync(path); + + if (context.smlinks.has(path)) { + for (const linkedPath of context.smlinks.get(path)!) { + symlinkSync(path, linkedPath); + } + + context.smlinks.delete(path); + } + + populateDirectorySync(path, content as FilesStructure, context); + } + } else { + writeFileSync(path, content); + + if (context.smlinks.has(path)) { + for (const linkedPath of context.smlinks.get(path)!) { + symlinkSync(path, linkedPath); + } + + context.smlinks.delete(path); + } + } + } +} + +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/e2e-test-kit/src/index.ts b/packages/e2e-test-kit/src/index.ts index 76cca86b0..b4c89719b 100644 --- a/packages/e2e-test-kit/src/index.ts +++ b/packages/e2e-test-kit/src/index.ts @@ -6,5 +6,16 @@ export { evalCssJSModule, webpackTest, } from './webpack-in-memory-test'; +export { + createCliTester, + loadDirSync, + populateDirectorySync, + runCliCodeMod, + runCliSync, + runFormatCliSync, + writeToExistingFile, + escapeRegExp, + smlinkSymbol, +} from './cli-test-kit'; export { runServer } from './run-server'; export { DTSKit } from './dts-kit';