@next-core/build-next-bricks 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import build from "../src/build.js";
5
+ import scanBricks from "../src/scanBricks.js";
6
+
7
+ try {
8
+ const startTime = Date.now();
9
+
10
+ const packageDir = process.cwd();
11
+ const configJs = path.join(packageDir, "build.config.js");
12
+ /** @type {import("@next-core/build-next-bricks").BuildNextBricksConfig} */
13
+ let config = {};
14
+ if (existsSync(configJs)) {
15
+ config = (await import(configJs)).default;
16
+ }
17
+
18
+ if (!config.type || config.type === "bricks") {
19
+ const scanBricksStartAt = performance.now();
20
+ Object.assign(config, await scanBricks(packageDir));
21
+ const scanBricksCost = Math.round(performance.now() - scanBricksStartAt);
22
+ console.log(
23
+ "Scan bricks done in",
24
+ scanBricksCost < 1000
25
+ ? `${scanBricksCost}ms`
26
+ : `${(scanBricksCost / 1000).toFixed(2)}s`
27
+ );
28
+ }
29
+
30
+ const compiler = await build(config);
31
+
32
+ const watch = process.argv.includes("--watch");
33
+
34
+ if (watch) {
35
+ compiler.watch({}, (err, stats) => {
36
+ if (err || stats.hasErrors()) {
37
+ console.error("Failed to build bricks:");
38
+ console.error(err || stats.toString());
39
+ } else {
40
+ console.log("Build bricks done in watch mode");
41
+ }
42
+ });
43
+ } else {
44
+ await new Promise((resolve, reject) => {
45
+ compiler.run((err, stats) => {
46
+ if (err || stats.hasErrors()) {
47
+ console.error("Failed to build bricks:");
48
+ reject(err || stats.toString());
49
+ } else {
50
+ resolve();
51
+ }
52
+ });
53
+ });
54
+
55
+ console.log(
56
+ `Build bricks done in ${((Date.now() - startTime) / 1000).toFixed(2)}s`
57
+ );
58
+ }
59
+ } catch (e) {
60
+ console.error(e);
61
+ process.exitCode = 1;
62
+ }
package/index.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Compiler, Configuration, RuleSetRule, container } from "webpack";
2
+
3
+ export declare function build(config: BuildNextBricksConfig): Compiler;
4
+
5
+ // Types of `SharedConfig` and `SharedObject` are copied from webpack.
6
+
7
+ /**
8
+ * Advanced configuration for modules that should be shared in the share scope.
9
+ */
10
+ interface SharedConfig {
11
+ /**
12
+ * Include the provided and fallback module directly instead behind an async request. This allows to use this shared module in initial load too. All possible shared modules need to be eager too.
13
+ */
14
+ eager?: boolean;
15
+
16
+ /**
17
+ * Provided module that should be provided to share scope. Also acts as fallback module if no shared module is found in share scope or version isn't valid. Defaults to the property name.
18
+ */
19
+ import?: string | false;
20
+
21
+ /**
22
+ * Package name to determine required version from description file. This is only needed when package name can't be automatically determined from request.
23
+ */
24
+ packageName?: string;
25
+
26
+ /**
27
+ * Version requirement from module in share scope.
28
+ */
29
+ requiredVersion?: string | false;
30
+
31
+ /**
32
+ * Module is looked up under this key from the share scope.
33
+ */
34
+ shareKey?: string;
35
+
36
+ /**
37
+ * Share scope name.
38
+ */
39
+ shareScope?: string;
40
+
41
+ /**
42
+ * Allow only a single version of the shared module in share scope (disabled by default).
43
+ */
44
+ singleton?: boolean;
45
+
46
+ /**
47
+ * Do not accept shared module if version is not valid (defaults to yes, if local fallback module is available and shared module is not a singleton, otherwise no, has no effect if there is no required version specified).
48
+ */
49
+ strictVersion?: boolean;
50
+
51
+ /**
52
+ * Version of the provided module. Will replace lower matching versions, but not higher.
53
+ */
54
+ version?: string | false;
55
+ }
56
+
57
+ /**
58
+ * Modules that should be shared in the share scope. Property names are used to match requested modules in this compilation. Relative requests are resolved, module requests are matched unresolved, absolute paths will match resolved requests. A trailing slash will match all requests with this prefix. In this case shareKey must also have a trailing slash.
59
+ */
60
+ interface SharedObject {
61
+ [index: string]: string | SharedConfig;
62
+ }
63
+
64
+ export interface BuildNextBricksConfig {
65
+ type?: "bricks" | "container" | "brick-playground";
66
+ mode?: "development" | "production";
67
+ entry?: Record<string, string>;
68
+ extractCss?: boolean;
69
+ plugins?: Configuration["plugins"];
70
+ moduleRules?: RuleSetRule[];
71
+ exposes?: ConstructorParameters<typeof container.ModuleFederationPlugin>;
72
+ dependencies?: Record<string, string[]>;
73
+ optimization?: Configuration["optimization"];
74
+ moduleFederationShared?: SharedObject;
75
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@next-core/build-next-bricks",
3
+ "version": "1.0.2",
4
+ "description": "Build next bricks",
5
+ "homepage": "https://github.com/easyops-cn/next-core/tree/master/packages/build-next-bricks",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "typings": "./index.d.ts",
9
+ "bin": {
10
+ "build-next-bricks": "./bin/build-next-bricks.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "index.d.ts"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./index.d.ts",
20
+ "import": "./src/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git@github.com:easyops-cn/next-core.git"
27
+ },
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=16"
31
+ },
32
+ "dependencies": {
33
+ "@babel/parser": "^7.21.2",
34
+ "@babel/traverse": "^7.21.2",
35
+ "@svgr/webpack": "^6.5.1",
36
+ "babel-loader": "^9.1.2",
37
+ "css-loader": "^6.7.3",
38
+ "cssnano": "^5.1.15",
39
+ "cssnano-preset-lite": "^2.1.3",
40
+ "lodash": "^4.17.21",
41
+ "mini-css-extract-plugin": "^2.7.5",
42
+ "postcss": "^8.4.21",
43
+ "postcss-loader": "^7.1.0",
44
+ "postcss-preset-env": "^8.0.1",
45
+ "style-loader": "^3.3.1",
46
+ "typescript": "^5.0.2",
47
+ "webpack": "^5.76.2"
48
+ },
49
+ "gitHead": "ffd8b31a5d99db5af3b4ec7675ff2d6541eb1338"
50
+ }
@@ -0,0 +1,69 @@
1
+ import webpack from "webpack";
2
+
3
+ const pluginName = "EmitBricksJsonPlugin";
4
+
5
+ export default class EmitBricksJsonPlugin {
6
+ /**
7
+ * @param {{ packageName: string; bricks: string[]; processors: string[]; dependencies: Record<string, string[]>; }} options
8
+ */
9
+ constructor(options) {
10
+ this.packageName = options.packageName;
11
+ this.bricks = options.bricks;
12
+ this.processors = options.processors;
13
+ this.dependencies = options.dependencies;
14
+ }
15
+
16
+ /**
17
+ * @param {import("webpack").Compiler} compiler
18
+ */
19
+ apply(compiler) {
20
+ // Ref https://github.com/jantimon/html-webpack-plugin/blob/d5ce5a8f2d12a2450a65ec51c285dd54e36cd921/index.js#L209
21
+ compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
22
+ compilation.hooks.processAssets.tapAsync(
23
+ {
24
+ name: pluginName,
25
+ stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
26
+ },
27
+ (compilationAssets, callback) => {
28
+ const jsEntries = Object.keys(compilationAssets).filter(
29
+ (filePath) =>
30
+ filePath.startsWith("index.") && filePath.endsWith(".js")
31
+ );
32
+ if (!jsEntries) {
33
+ throw new Error(
34
+ `No js files in dist of bricks/${this.packageName}`
35
+ );
36
+ }
37
+ if (jsEntries.length > 1) {
38
+ throw new Error(
39
+ `Only a single js entry is allowed in dist of bricks/${this.packageName}, but ${jsEntries.length} entries were found`
40
+ );
41
+ }
42
+ const jsFilePath = `bricks/${this.packageName}/dist/${jsEntries[0]}`;
43
+
44
+ const bricksJson = JSON.stringify(
45
+ {
46
+ id: `bricks/${this.packageName}`,
47
+ bricks: this.bricks,
48
+ processors: this.processors,
49
+ dependencies: this.dependencies,
50
+ filePath: jsFilePath,
51
+ },
52
+ null,
53
+ 2
54
+ );
55
+
56
+ compilation.emitAsset(
57
+ "bricks.json",
58
+ new webpack.sources.RawSource(bricksJson, false)
59
+ );
60
+
61
+ console.log("Defined bricks:", this.bricks);
62
+ console.log("Defined processors:", this.processors);
63
+ console.log("Found dependencies:", this.dependencies);
64
+ callback();
65
+ }
66
+ );
67
+ });
68
+ }
69
+ }
package/src/build.js ADDED
@@ -0,0 +1,377 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import webpack from "webpack";
5
+ import MiniCssExtractPlugin from "mini-css-extract-plugin";
6
+ import postcssPresetEnv from "postcss-preset-env";
7
+ import cssnano from "cssnano";
8
+ import cssnanoPresetLite from "cssnano-preset-lite";
9
+ import EmitBricksJsonPlugin from "./EmitBricksJsonPlugin.js";
10
+ import getCamelPackageName from "./getCamelPackageName.js";
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ const { SourceMapDevToolPlugin, IgnorePlugin, ContextReplacementPlugin } =
15
+ webpack;
16
+ const { ModuleFederationPlugin } = webpack.container;
17
+
18
+ const getCssLoaders = (cssOptions) => [
19
+ {
20
+ loader: "css-loader",
21
+ options: {
22
+ sourceMap: false,
23
+ importLoaders: 1,
24
+ ...cssOptions,
25
+ },
26
+ },
27
+ {
28
+ loader: "postcss-loader",
29
+ options: {
30
+ sourceMap: false,
31
+ postcssOptions: {
32
+ plugins: [
33
+ postcssPresetEnv({
34
+ stage: 3,
35
+ }),
36
+ cssnano({
37
+ preset: cssnanoPresetLite({
38
+ discardComments: {
39
+ removeAll: true,
40
+ },
41
+ }),
42
+ }),
43
+ ],
44
+ },
45
+ },
46
+ },
47
+ ];
48
+
49
+ /**
50
+ * @param {import("@next-core/build-next-bricks").BuildNextBricksConfig} config
51
+ */
52
+ export default async function build(config) {
53
+ const packageDir = process.cwd();
54
+ // const isContainer = config.type === "container";
55
+ const isBricks = !config.type || config.type === "bricks";
56
+ const mode = config.mode || process.env.NODE_ENV;
57
+
58
+ const packageJsonFile = await readFile(
59
+ path.join(packageDir, "package.json"),
60
+ { encoding: "utf-8" }
61
+ );
62
+ const packageJson = JSON.parse(packageJsonFile);
63
+ const packageName = packageJson.name.split("/").pop();
64
+ const camelPackageName = getCamelPackageName(packageName);
65
+ const libName = isBricks ? `bricks/${packageName}` : config.type;
66
+
67
+ const sharedSingletonPackages = [
68
+ "history",
69
+ "i18next",
70
+ "lodash",
71
+ "moment",
72
+ "moment/locale/zh-cn.js",
73
+ "js-yaml",
74
+ "i18next-browser-languagedetector",
75
+ "react-i18next",
76
+ "@next-core/runtime",
77
+ "@next-core/http",
78
+ "@next-core/theme",
79
+ "@next-core/cook",
80
+ "@next-core/i18n",
81
+ "@next-core/i18n/react",
82
+ "@next-core/inject",
83
+ "@next-core/loader",
84
+ "@next-core/supply",
85
+ "@next-core/utils/general",
86
+ "@next-core/utils/storyboard",
87
+ ];
88
+
89
+ const sharedPackages = [
90
+ "react",
91
+ "react-dom",
92
+ "@next-core/element",
93
+ "@next-core/react-element",
94
+ "@next-core/react-runtime",
95
+ ...sharedSingletonPackages,
96
+ ];
97
+
98
+ /** @type {import("@next-core/build-next-bricks").BuildNextBricksConfig["moduleFederationShared"]} */
99
+ const shared = Object.fromEntries(
100
+ (
101
+ await Promise.all(
102
+ sharedPackages.map(async (dep) => {
103
+ /** @type {string} */
104
+ let depPackageJsonPath;
105
+ const depPkgName = dep
106
+ .split("/")
107
+ .slice(0, dep.startsWith("@") ? 2 : 1)
108
+ .join("/");
109
+ try {
110
+ depPackageJsonPath = require.resolve(`${depPkgName}/package.json`, {
111
+ paths: [packageDir],
112
+ });
113
+ } catch (e) {
114
+ console.error(`Shared package not found: "${dep}"`);
115
+ return;
116
+ }
117
+ const depPackageJsonFile = await readFile(depPackageJsonPath, {
118
+ encoding: "utf-8",
119
+ });
120
+ const depPackageJson = JSON.parse(depPackageJsonFile);
121
+ const customized = config.moduleFederationShared?.[dep];
122
+ if (typeof customized === "string") {
123
+ return;
124
+ }
125
+ return [
126
+ dep,
127
+ {
128
+ singleton: sharedSingletonPackages.includes(dep),
129
+ version: depPackageJson.version,
130
+ requiredVersion:
131
+ packageJson.peerDependencies?.[depPkgName] ??
132
+ packageJson.devDependencies?.[depPkgName] ??
133
+ packageJson.dependencies?.[depPkgName],
134
+ ...customized,
135
+ },
136
+ ];
137
+ })
138
+ )
139
+ ).filter(Boolean)
140
+ );
141
+
142
+ // console.log(packageName, "shared:", shared);
143
+
144
+ /** @type {string[]} */
145
+ const bricks = [];
146
+ /** @type {string[]} */
147
+ const processors = [];
148
+ if (isBricks) {
149
+ for (const key of Object.keys(config.exposes)) {
150
+ const segments = key.split("/");
151
+ const name = segments.pop();
152
+ const namespace = segments.pop();
153
+ if (namespace === "processors") {
154
+ processors.push(`${camelPackageName}.${name}`);
155
+ } else {
156
+ bricks.push(`${packageName}.${name}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ /** @type {Record<string, { import: string; name: string; }>} */
162
+ const extraExposes = {};
163
+ // const initializeTsPath = path.join(packageDir, "src/initialize.ts");
164
+ // if (fs.existsSync(initializeTsPath)) {
165
+ // extraExposes.initialize = {
166
+ // import: `./${path.relative(packageDir, initializeTsPath)}`,
167
+ // name: "initialize",
168
+ // };
169
+ // }
170
+
171
+ const outputPath = path.join(packageDir, "dist");
172
+ const chunksDir = isBricks ? "chunks/" : "";
173
+
174
+ return webpack({
175
+ entry: config.entry || {
176
+ main: "./src/index",
177
+ },
178
+ mode,
179
+ devServer: {
180
+ static: {
181
+ directory: outputPath,
182
+ },
183
+ port: 3001,
184
+ },
185
+ output: {
186
+ path: outputPath,
187
+ filename: `${chunksDir}[name].${
188
+ mode === "development" ? "bundle" : "[contenthash]"
189
+ }.js`,
190
+ // filename: "[name].bundle.js",
191
+ publicPath: "auto",
192
+ hashDigestLength: 8,
193
+ chunkFilename: `${chunksDir}[name]${
194
+ mode === "development" ? "" : ".[contenthash]"
195
+ }.js`,
196
+ clean: true,
197
+ },
198
+ resolve: {
199
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
200
+ extensionAlias: {
201
+ ".js": [".ts", ".tsx", ".js", ".jsx"],
202
+ },
203
+ },
204
+ module: {
205
+ rules: [
206
+ {
207
+ test: /\.css$/,
208
+ exclude: /\.(module|shadow|lazy)\.css$/,
209
+ // resourceQuery: {
210
+ // not: /shadow/
211
+ // },
212
+ sideEffects: true,
213
+ use: [
214
+ config.extractCss ? MiniCssExtractPlugin.loader : "style-loader",
215
+ ...getCssLoaders(),
216
+ ],
217
+ },
218
+ {
219
+ test: /\.shadow\.css$/,
220
+ use: [
221
+ ...getCssLoaders({
222
+ exportType: "string",
223
+ }),
224
+ ],
225
+ },
226
+ // {
227
+ // test: /\.css$/,
228
+ // resourceQuery: /shadow/,
229
+ // use: [
230
+ // ...getCssLoaders({
231
+ // exportType: "string",
232
+ // }),
233
+ // ],
234
+ // },
235
+ {
236
+ test: /\.[tj]sx?$/,
237
+ loader: "babel-loader",
238
+ exclude: /node_modules/,
239
+ options: {
240
+ rootMode: "upward",
241
+ },
242
+ },
243
+ {
244
+ test: /\.svg$/i,
245
+ issuer(input) {
246
+ // The issuer is null (or an empty string) for dynamic import
247
+ return !input || /\.[jt]sx?$/.test(input);
248
+ },
249
+ use: [
250
+ {
251
+ loader: "babel-loader",
252
+ options: {
253
+ rootMode: "upward",
254
+ },
255
+ },
256
+ {
257
+ loader: "@svgr/webpack",
258
+ options: {
259
+ babel: false,
260
+ icon: true,
261
+ svgoConfig: {
262
+ plugins: [
263
+ {
264
+ name: "preset-default",
265
+ params: {
266
+ overrides: {
267
+ // Keep `viewbox`
268
+ removeViewBox: false,
269
+ convertColors: {
270
+ currentColor: true,
271
+ },
272
+ },
273
+ },
274
+ },
275
+ ],
276
+ },
277
+ },
278
+ },
279
+ ],
280
+ },
281
+ ...(config.moduleRules || []),
282
+ ],
283
+ },
284
+ devtool: false,
285
+ optimization:
286
+ config.optimization ||
287
+ (isBricks
288
+ ? {
289
+ splitChunks: {
290
+ cacheGroups: {
291
+ react: {
292
+ test: /[\\/]node_modules[\\/](?:react(?:-dom)?|scheduler)[\\/]/,
293
+ priority: -10,
294
+ reuseExistingChunk: true,
295
+ name: "react",
296
+ },
297
+ default: {
298
+ minChunks: 2,
299
+ priority: -20,
300
+ reuseExistingChunk: true,
301
+ },
302
+ },
303
+ },
304
+ }
305
+ : undefined),
306
+ plugins: [
307
+ new SourceMapDevToolPlugin({
308
+ filename: "[file].map",
309
+ // Do not generate source map for these vendors:
310
+ exclude: [
311
+ // No source maps for React,ReactDOM,@next-core/theme
312
+ /^chunks\/(?:2?784|(?:2?8)?316|628|react)(?:\.[0-9a-f]+|\.bundle)?\.js$/,
313
+ /^chunks\/(?:vendors-)?node_modules_/,
314
+ /^chunks\/(?:easyops|fa|antd)-icons\//,
315
+ /^(?:vendors|polyfill)(?:\.[0-9a-f]+|\.bundle)?\.js$/,
316
+ ],
317
+ }),
318
+
319
+ new ModuleFederationPlugin({
320
+ name: libName,
321
+ shared: {
322
+ ...config.moduleFederationShared,
323
+ ...shared,
324
+ },
325
+ ...(isBricks
326
+ ? {
327
+ library: { type: "window", name: libName },
328
+ filename:
329
+ mode === "development"
330
+ ? "index.bundle.js"
331
+ : "index.[contenthash].js",
332
+ exposes: {
333
+ ...config.exposes,
334
+ ...extraExposes,
335
+ },
336
+ }
337
+ : null),
338
+ }),
339
+
340
+ ...(config.extractCss
341
+ ? [
342
+ new MiniCssExtractPlugin({
343
+ filename:
344
+ mode === "development"
345
+ ? "[name].bundle.css"
346
+ : "[name].[contenthash].css",
347
+ chunkFilename:
348
+ mode === "development"
349
+ ? `${chunksDir}[name].css`
350
+ : `${chunksDir}[name].[contenthash].css`,
351
+ }),
352
+ ]
353
+ : []),
354
+
355
+ ...(isBricks
356
+ ? [
357
+ new EmitBricksJsonPlugin({
358
+ packageName,
359
+ bricks,
360
+ processors,
361
+ dependencies: config.dependencies,
362
+ }),
363
+ ]
364
+ : []),
365
+
366
+ new ContextReplacementPlugin(/moment[/\\]locale$/, /zh|en/),
367
+
368
+ new IgnorePlugin({
369
+ // - `esprima` and `buffer` are optional imported by `js-yaml`
370
+ // we don't need them.
371
+ resourceRegExp: /^(?:esprima|buffer)$/,
372
+ }),
373
+
374
+ ...(config.plugins || []),
375
+ ],
376
+ });
377
+ }
@@ -0,0 +1,10 @@
1
+ // @ts-check
2
+ /**
3
+ * @param {string} packageName
4
+ * @returns {string}
5
+ */
6
+ export default function getCamelPackageName(packageName) {
7
+ return packageName
8
+ .replace(/-[a-z]/g, (match) => match[1].toUpperCase())
9
+ .replace(/-[0-9]/g, (match) => `_${match[1]}`);
10
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import build from "./build.js";
2
+
3
+ export { build };
@@ -0,0 +1,468 @@
1
+ import path from "node:path";
2
+ import fs, { existsSync, statSync } from "node:fs";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { parse } from "@babel/parser";
5
+ import babelTraverse from "@babel/traverse";
6
+ import _ from "lodash";
7
+ import getCamelPackageName from "./getCamelPackageName.js";
8
+
9
+ const { default: traverse } = babelTraverse;
10
+ const { escapeRegExp } = _;
11
+
12
+ const validBrickName =
13
+ /^[a-z][a-z0-9]*(-[a-z0-9]+)*\.[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
14
+ const validProcessorName = /^[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$/;
15
+ const validExposeName = /^[-\w]+$/;
16
+
17
+ /**
18
+ * Scan defined bricks by AST.
19
+ *
20
+ * @param {string} packageDir
21
+ * @returns {Promise<{exposes: Record<string, { import: string; name: string; }; dependencies: Record<string, string[]>}>>}
22
+ */
23
+ export default async function scanBricks(packageDir) {
24
+ /** @type {Map<string, { import: string; name: string; }>} */
25
+ const exposes = new Map();
26
+ /** @type {Record<string, string[]>} */
27
+ const specifiedDeps = {};
28
+ /** @type {Set<string>} */
29
+ const processedFiles = new Set();
30
+
31
+ const packageJsonFile = await readFile(
32
+ path.join(packageDir, "package.json"),
33
+ { encoding: "utf-8" }
34
+ );
35
+ const packageJson = JSON.parse(packageJsonFile);
36
+ /** @type {string} */
37
+ const packageName = packageJson.name.split("/").pop();
38
+ const camelPackageName = getCamelPackageName(packageName);
39
+
40
+ /** @type {Map<string, Set<string>} */
41
+ const usingWrappedBricks = new Map();
42
+
43
+ /** @type {Map<string, string} */
44
+ const brickSourceFiles = new Map();
45
+
46
+ /** @type {Map<string, Set<string>} */
47
+ const importsMap = new Map();
48
+
49
+ /**
50
+ *
51
+ * @param {string} filePath
52
+ * @param {string | undefined} overrideImport
53
+ */
54
+ async function scanByFile(filePath, overrideImport) {
55
+ if (processedFiles.has(filePath)) {
56
+ console.warn(
57
+ "[scan-bricks] warn: the file has already been scanned:",
58
+ filePath
59
+ );
60
+ return;
61
+ }
62
+ processedFiles.add(filePath);
63
+ const dirname = path.dirname(filePath);
64
+ const extname = path.extname(filePath);
65
+ const content = await readFile(filePath, "utf-8");
66
+
67
+ /** @type {ReturnType<typeof import("@babel/parser").parse>} */
68
+ let ast;
69
+ try {
70
+ ast = parse(content, {
71
+ sourceType: "module",
72
+ plugins: [
73
+ (extname === ".ts" || extname === ".tsx") && "typescript",
74
+ (extname === ".jsx" || extname === ".tsx") && "jsx",
75
+ "decorators",
76
+ "decoratorAutoAccessors",
77
+ ].filter(Boolean),
78
+ });
79
+ } catch (e) {
80
+ console.error("Babel parse failed:", filePath);
81
+ console.error(e);
82
+ }
83
+
84
+ /** @type {string | undefined} */
85
+ let nextOverrideImport = overrideImport;
86
+ if (content.startsWith("// Merge bricks")) {
87
+ nextOverrideImport = filePath;
88
+ }
89
+
90
+ /**
91
+ *
92
+ * @param {string} originalName
93
+ * @returns {string}
94
+ */
95
+ function getExposeName(originalName) {
96
+ if (overrideImport) {
97
+ const exposeName = path.basename(
98
+ overrideImport.replace(/\.[^.]+$/, "").replace(/\/index$/, "")
99
+ );
100
+ if (!validExposeName.test(exposeName)) {
101
+ throw new Error(
102
+ `Invalid filename for merging bricks: "${exposeName}", only alphabets/digits/hyphens/underscores are allowed`
103
+ );
104
+ }
105
+ return exposeName;
106
+ }
107
+ return originalName;
108
+ }
109
+
110
+ /** @type {Map<string, Set<string>} */
111
+ const importPaths = new Map();
112
+
113
+ /**
114
+ * @param {string} dir
115
+ * @param {string} file
116
+ */
117
+ function addImportFile(dir, file) {
118
+ const files = importPaths.get(dir);
119
+ if (files) {
120
+ files.add(file);
121
+ } else {
122
+ importPaths.set(dir, new Set([file]));
123
+ }
124
+ }
125
+
126
+ traverse(ast, {
127
+ CallExpression({ node: { callee, arguments: args } }) {
128
+ // Match `customProcessors.define(...)`
129
+ // Match `customElements.define(...)`
130
+ if (
131
+ callee.type === "MemberExpression" &&
132
+ callee.object.type === "Identifier" &&
133
+ callee.object.name === "customProcessors" &&
134
+ !callee.property.computed &&
135
+ callee.property.name === "define" &&
136
+ args.length === 2
137
+ ) {
138
+ const { type, value: fullName } = args[0];
139
+ if (type === "StringLiteral") {
140
+ const [processorNamespace, processorName] = fullName.split(".");
141
+ if (processorNamespace !== camelPackageName) {
142
+ throw new Error(
143
+ `Invalid custom processor: "${fullName}", expecting prefixed with the camelCase package name: "${camelPackageName}"`
144
+ );
145
+ }
146
+
147
+ if (!validProcessorName.test(fullName)) {
148
+ throw new Error(
149
+ `Invalid custom processor: "${fullName}", expecting format of "camelPackageName.camelProcessorName"`
150
+ );
151
+ }
152
+
153
+ exposes.set(`./processors/${processorName}`, {
154
+ import: `./${path
155
+ .relative(packageDir, overrideImport || filePath)
156
+ .replace(/\.[^.]+$/, "")
157
+ .replace(/\/index$/, "")}`,
158
+ name: getExposeName(processorName),
159
+ });
160
+ } else if (packageName !== "v2-adapter") {
161
+ throw new Error(
162
+ "Please call `customProcessors.define()` only with literal string"
163
+ );
164
+ }
165
+ } else if (
166
+ callee.type === "MemberExpression" &&
167
+ callee.object.type === "Identifier" &&
168
+ callee.object.name === "customElements" &&
169
+ !callee.property.computed &&
170
+ callee.property.name === "define" &&
171
+ args.length === 2
172
+ ) {
173
+ const { type, value: fullName } = args[0];
174
+ if (type === "StringLiteral") {
175
+ const [brickNamespace, brickName] = fullName.split(".");
176
+ if (brickNamespace !== packageName) {
177
+ throw new Error(
178
+ `Invalid brick: "${fullName}", expecting prefixed with the package name: "${packageName}"`
179
+ );
180
+ }
181
+
182
+ if (!validBrickName.test(fullName)) {
183
+ throw new Error(
184
+ `Invalid brick: "${fullName}", expecting: "PACKAGE-NAME.BRICK-NAME", where PACKAGE-NAME and BRICK-NAME must be lower-kebab-case, and BRICK-NAME must include a \`-\``
185
+ );
186
+ }
187
+
188
+ if (brickName.startsWith("tpl-")) {
189
+ throw new Error(
190
+ `Invalid brick: "${fullName}", the brick name cannot be started with "tpl-"`
191
+ );
192
+ }
193
+
194
+ brickSourceFiles.set(fullName, filePath);
195
+
196
+ exposes.set(`./${brickName}`, {
197
+ import: `./${path
198
+ .relative(packageDir, overrideImport || filePath)
199
+ .replace(/\.[^.]+$/, "")
200
+ .replace(/\/index$/, "")}`,
201
+ name: getExposeName(brickName),
202
+ });
203
+ } else {
204
+ throw new Error(
205
+ "Please call `customElements.define()` only with literal string"
206
+ );
207
+ }
208
+ } else if (
209
+ callee.type === "MemberExpression" &&
210
+ callee.object.type === "Identifier" &&
211
+ callee.object.name === "customTemplates" &&
212
+ !callee.property.computed &&
213
+ callee.property.name === "define" &&
214
+ args.length === 2
215
+ ) {
216
+ const { type, value: fullName } = args[0];
217
+ if (type === "StringLiteral") {
218
+ const [brickNamespace, brickName] = fullName.split(".");
219
+ if (brickNamespace !== packageName) {
220
+ throw new Error(
221
+ `Invalid custom template: "${fullName}", expecting prefixed with the package name: "${packageName}"`
222
+ );
223
+ }
224
+
225
+ if (!validBrickName.test(fullName)) {
226
+ throw new Error(
227
+ `Invalid custom template: "${fullName}", expecting: "PACKAGE-NAME.BRICK-NAME", where PACKAGE-NAME and BRICK-NAME must be lower-kebab-case, and BRICK-NAME must include a \`-\``
228
+ );
229
+ }
230
+
231
+ if (!brickName.startsWith("tpl-")) {
232
+ throw new Error(
233
+ `Invalid custom template: "${fullName}", the custom template name must be started with "tpl-"`
234
+ );
235
+ }
236
+
237
+ exposes.set(`./${brickName}`, {
238
+ import: `./${path
239
+ .relative(packageDir, overrideImport || filePath)
240
+ .replace(/\.[^.]+$/, "")
241
+ .replace(/\/index$/, "")}`,
242
+ name: getExposeName(brickName),
243
+ });
244
+ } else if (packageName !== "v2-adapter") {
245
+ throw new Error(
246
+ "Please call `customTemplates.define()` only with literal string"
247
+ );
248
+ }
249
+ } else if (
250
+ callee.type === "Identifier" &&
251
+ callee.name === "wrapBrick" &&
252
+ args.length >= 1
253
+ ) {
254
+ const { type, value: brickName } = args[0];
255
+ if (type !== "StringLiteral") {
256
+ throw new Error("Please call `wrapBrick` only with literal string");
257
+ }
258
+ const bricks = usingWrappedBricks.get(filePath);
259
+ if (bricks) {
260
+ bricks.add(brickName);
261
+ } else {
262
+ usingWrappedBricks.set(filePath, new Set([brickName]));
263
+ }
264
+ }
265
+ },
266
+ Decorator({ node: { expression } }) {
267
+ // Match `@defineElement(...)`
268
+ if (
269
+ expression.type === "CallExpression" &&
270
+ expression.callee.type === "Identifier" &&
271
+ expression.callee.name === "defineElement" &&
272
+ expression.arguments.length > 0
273
+ ) {
274
+ if (expression.arguments[0].type !== "StringLiteral") {
275
+ throw new Error(
276
+ "Please call `@defineElement()` only with literal string"
277
+ );
278
+ }
279
+
280
+ const fullName = expression.arguments[0].value;
281
+ const [brickNamespace, brickName] = fullName.split(".");
282
+
283
+ if (brickNamespace !== packageName) {
284
+ throw new Error(
285
+ `Invalid brick: "${fullName}", expecting prefixed with the package name: "${packageName}"`
286
+ );
287
+ }
288
+
289
+ if (!validBrickName.test(fullName)) {
290
+ throw new Error(
291
+ `Invalid brick: "${fullName}", expecting: "PACKAGE-NAME.BRICK-NAME", where PACKAGE-NAME and BRICK-NAME must be lower-kebab-case, and BRICK-NAME must include a \`-\``
292
+ );
293
+ }
294
+
295
+ if (brickName.startsWith("tpl-")) {
296
+ throw new Error(
297
+ `Invalid brick: "${fullName}", the brick name cannot be started with "tpl-"`
298
+ );
299
+ }
300
+
301
+ const defineOptions = expression.arguments[1];
302
+ const deps = [];
303
+ if (defineOptions && defineOptions.type === "ObjectExpression") {
304
+ /** @type {import("@babel/types").ObjectProperty} */
305
+ const brickDeps = defineOptions.properties.find(
306
+ (prop) =>
307
+ prop.type === "ObjectProperty" &&
308
+ prop.key.type === "Identifier" &&
309
+ prop.key.name === "dependencies" &&
310
+ !prop.computed
311
+ );
312
+ if (brickDeps) {
313
+ if (brickDeps.value.type === "ArrayExpression") {
314
+ for (const item of brickDeps.value.elements) {
315
+ if (item.type === "StringLiteral") {
316
+ deps.push(item.value);
317
+ } else {
318
+ throw new Error(
319
+ `Invalid item in brick dependencies: ${item.type} of brick: "${fullName}", expecting only StringLiteral`
320
+ );
321
+ }
322
+ }
323
+ } else {
324
+ throw new Error(
325
+ `Invalid brick dependencies: ${brickDeps.value.type} of brick: "${fullName}", expecting only ArrayExpression`
326
+ );
327
+ }
328
+ }
329
+ }
330
+ if (deps.length > 0) {
331
+ specifiedDeps[fullName] = deps;
332
+ }
333
+
334
+ brickSourceFiles.set(fullName, filePath);
335
+
336
+ exposes.set(`./${brickName}`, {
337
+ import: `./${path
338
+ .relative(packageDir, overrideImport || filePath)
339
+ .replace(/\.[^.]+$/, "")
340
+ .replace(/\/index$/, "")}`,
341
+ name: getExposeName(brickName),
342
+ });
343
+ }
344
+ },
345
+ ImportDeclaration({ node: { source, importKind, specifiers } }) {
346
+ // Match `import "..."`
347
+ if (
348
+ source.type === "StringLiteral" &&
349
+ // Ignore import from node modules.
350
+ (source.value.startsWith("./") || source.value.startsWith("../")) &&
351
+ // Ignore `import type {...} from "..."`
352
+ importKind === "value"
353
+ // Ignore `import { ... } from "..."`
354
+ // && specifiers.length === 0
355
+ ) {
356
+ const importPath = path.resolve(dirname, source.value);
357
+ const lastName = path.basename(importPath);
358
+ const matchExtension = /\.[tj]sx?$/.test(lastName);
359
+ const noExtension = !lastName.includes(".");
360
+ if (matchExtension || noExtension) {
361
+ addImportFile(
362
+ path.dirname(importPath),
363
+ lastName.replace(/\.[^.]+$/, "")
364
+ );
365
+ }
366
+ if (
367
+ noExtension &&
368
+ existsSync(importPath) &&
369
+ statSync(importPath).isDirectory()
370
+ ) {
371
+ // When matching `import "./directory"`,
372
+ // also look for "./directory/index.*"
373
+ addImportFile(importPath, "index");
374
+ }
375
+ }
376
+ },
377
+ });
378
+
379
+ await Promise.all(
380
+ [...importPaths.entries()].map((item) =>
381
+ scanByImport(item, filePath, nextOverrideImport)
382
+ )
383
+ );
384
+ }
385
+
386
+ /**
387
+ *
388
+ * @param {[string, Set<string>]} importEntry
389
+ * @param {string} importFrom
390
+ * @param {string | undefined} overrideImport
391
+ */
392
+ async function scanByImport([dirname, files], importFrom, overrideImport) {
393
+ const dirents = await readdir(dirname, { withFileTypes: true });
394
+ const possibleFilenames = [...files].map(
395
+ (filename) => new RegExp(`${escapeRegExp(filename)}\\.[tj]sx?$`)
396
+ );
397
+ await Promise.all(
398
+ dirents.map((dirent) => {
399
+ if (
400
+ dirent.isFile() &&
401
+ possibleFilenames.some((regex) => regex.test(dirent.name))
402
+ ) {
403
+ const filePath = path.resolve(dirname, dirent.name);
404
+ const imports = importsMap.get(importFrom);
405
+ if (imports) {
406
+ imports.add(filePath);
407
+ } else {
408
+ importsMap.set(importFrom, new Set([filePath]));
409
+ }
410
+ return scanByFile(filePath, overrideImport);
411
+ }
412
+ })
413
+ );
414
+ }
415
+
416
+ const bootstrapTsPath = path.join(packageDir, "src/bootstrap.ts");
417
+ if (!fs.existsSync(bootstrapTsPath)) {
418
+ throw new Error(`File not found: ${bootstrapTsPath}`);
419
+ }
420
+
421
+ await scanByFile(bootstrapTsPath);
422
+
423
+ // console.log("usingWrappedBricks:", usingWrappedBricks);
424
+ // console.log("brickSourceFiles:", brickSourceFiles);
425
+ // console.log("importsMap:", importsMap);
426
+
427
+ // Find brick dependencies by static analysis.
428
+ /** @type {Record<string, string[]>} */
429
+ const analyzedDeps = {};
430
+ for (const [brickName, sourcePath] of brickSourceFiles.entries()) {
431
+ /** @type {Set<string>} */
432
+ const deps = new Set();
433
+ /** @type {Set<string>} */
434
+ const analyzedFiles = new Set();
435
+ const analyze = (filePath) => {
436
+ if (analyzedFiles.has(filePath)) {
437
+ return;
438
+ }
439
+ if (brickName === "button-with-icon") {
440
+ console.log("scan:", filePath);
441
+ }
442
+ analyzedFiles.add(filePath);
443
+ for (const dep of usingWrappedBricks.get(filePath) ?? []) {
444
+ // Do not dependent on itself
445
+ if (dep !== brickName) {
446
+ deps.add(dep);
447
+ }
448
+ }
449
+ for (const item of importsMap.get(filePath) ?? []) {
450
+ analyze(item);
451
+ }
452
+ };
453
+ analyze(sourcePath);
454
+ if (deps.size > 0) {
455
+ analyzedDeps[brickName] = [...deps];
456
+ }
457
+ }
458
+
459
+ // console.log("exposes:", exposes);
460
+
461
+ return {
462
+ exposes: Object.fromEntries([...exposes.entries()]),
463
+ dependencies: {
464
+ ...analyzedDeps,
465
+ ...specifiedDeps,
466
+ },
467
+ };
468
+ }