From 9008ab037b4f7d88730008124f2458187770ea23 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Tue, 10 Mar 2026 22:23:43 +0800 Subject: [PATCH 01/10] :truck: chore: migrate scratch-webpack-configuration Signed-off-by: SimonShiki --- UPSTREAM | 1 + package.json | 3 +- packages/infra/.editorconfig | 17 + packages/infra/.gitattributes | 1 + packages/infra/.gitignore | 130 ++++++ packages/infra/LICENSE | 14 + packages/infra/README.md | 202 +++++++++ packages/infra/package.json | 46 ++ packages/infra/src/index.cjs | 627 ++++++++++++++++++++++++++++ packages/infra/test/targets.test.js | 133 ++++++ yarn.lock | 5 + 11 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 packages/infra/.editorconfig create mode 100644 packages/infra/.gitattributes create mode 100644 packages/infra/.gitignore create mode 100644 packages/infra/LICENSE create mode 100644 packages/infra/README.md create mode 100644 packages/infra/package.json create mode 100644 packages/infra/src/index.cjs create mode 100644 packages/infra/test/targets.test.js diff --git a/UPSTREAM b/UPSTREAM index 29bcf51e..4c49fc11 100644 --- a/UPSTREAM +++ b/UPSTREAM @@ -8,3 +8,4 @@ scratch-storage 80b258d scratch-parser 7244904 scratch-audio 50b7ade eslint-config-scratch 87ee420 +scratch-webpack-configuration 31aad55 diff --git a/package.json b/package.json index 34d664fe..52062275 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "storage": "yarn workspace clipcc-storage", "paint": "yarn workspace clipcc-paint", "parser": "yarn workspace clipcc-parser", - "audio": "yarn workspace clipcc-audio" + "audio": "yarn workspace clipcc-audio", + "infra": "yarn workspace clipcc-webpack-configuration" }, "devDependencies": { "@changesets/changelog-git": "^0.2.1", diff --git a/packages/infra/.editorconfig b/packages/infra/.editorconfig new file mode 100644 index 00000000..3f4f10e8 --- /dev/null +++ b/packages/infra/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_size = 4 +trim_trailing_whitespace = true + +[*.js] +indent_style = space + +[package.json] +indent_size = 2 + +[renovate.json5] +indent_size = 2 diff --git a/packages/infra/.gitattributes b/packages/infra/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/packages/infra/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/packages/infra/.gitignore b/packages/infra/.gitignore new file mode 100644 index 00000000..c6bba591 --- /dev/null +++ b/packages/infra/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/packages/infra/LICENSE b/packages/infra/LICENSE new file mode 100644 index 00000000..0ebdcc96 --- /dev/null +++ b/packages/infra/LICENSE @@ -0,0 +1,14 @@ +BSD 3-Clause License + +Copyright (c) Scratch Foundation +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/infra/README.md b/packages/infra/README.md new file mode 100644 index 00000000..f277fe3d --- /dev/null +++ b/packages/infra/README.md @@ -0,0 +1,202 @@ +# scratch-webpack-configuration + +Shared configuration for Scratch's use of webpack + +## Usage + +Add something like this to your `webpack.config.*js` file: + +```javascript +import ScratchWebpackConfigBuilder from 'scratch-webpack-configuration'; + +const builder = new ScratchWebpackConfigBuilder( + { + rootPath: __dirname, + enableReact: true + }) + .setTarget('browserslist') + .addModuleRule({ + test: /\.css$/, + use: [/* CSS loaders */] + }) + .addPlugin(new CopyWebpackPlugin({ + patterns: [/* CopyWebpackPlugin patterns */] + }); + +if (process.env.FOO === 'bar') { + builder.addPlugin(new MyCustomPlugin()); +} + +module.exports = builder.get(); +``` + +Call `addModuleRule` and `addPlugin` as few or as many times as needed. If you need multiple configurations, you can +use `clone()` to share a base configuration and then add or override settings: + +```javascript +const baseConfig = new ScratchWebpackConfigBuilder({rootPath: __dirname, libraryName: 'my-library'}) + .addModuleRule({ + test: /\.foo$/, + use: [/* FOO loaders */] + }); + +const config1 = baseConfig.clone() + .setTarget('browserslist') + .merge({/* arbitrary configuration */}) + .addPlugin(new MyCustomPlugin('hi')); + +const config2 = baseConfig.clone() + .setTarget('node') + .addPlugin(new MyCustomPlugin('hello')); + +module.exports = [ + config1.get(), + config2.get() +]; +``` + +To load another workspace package from source without leaking that package's +loader rules into the current package, register it explicitly: + +```javascript +const builder = new ScratchWebpackConfigBuilder({ + rootPath: __dirname, + enableReact: true, + enableTs: true +}) + .addWorkspacePackage({ + name: 'clipcc-block', + rootPath: path.resolve(__dirname, '../block'), + moduleRules: [{ + test: /\.css$/, + use: 'raw-loader' + }] + }) + .setTarget('browserslist'); +``` + +The builder will: + +- resolve `clipcc-block` from the package `src/` directory +- include that source tree in the default JS/TS transpilation rules +- wrap its extra `module.rules` so they only apply to files inside that package + +## What it does + +- Sets up a default configuration that is suitable for most Scratch projects + - Use `enableReact` to enable React support + - Target `node` or `browserslist` (more targets will be added as needed) +- Adds `babel-loader` with the `@babel/preset-env` preset + - Adds `@babel/preset-react` if React support is enabled +- Adds `ts-loader` when TypeScript support is enabled + - By default it uses `transpileOnly: true` and `allowTsInNodeModules: true` + - Override this with `tsLoaderOptions`, or disable the defaults with `useDefaultTsLoaderOptions: false` +- Adds target-specific presets for `webpack` 5's `externals` and `externalsPresets` settings +- Target-specific output directory under `dist/` + - `browserslist` builds to `dist/web/` + - `node` builds to `dist/node/` +- Supports merging in arbitrary configuration with `merge({...})` +- Can register workspace packages from source with package-scoped webpack rules + +### Asset Modules + +This configuration makes webpack 5's [Asset Modules](https://webpack.js.org/guides/asset-modules/) available through +resource queries parameters: + +```js +import myImage from './my-image.png?asset'; // Use `asset` (let webpack decide) +import myImage from './my-image.png?resource'; // Use `asset/resource`, similar to `file-loader` +import myImage from './my-image.png?inline'; // Use `asset/inline`, similar to `url-loader` +import myImage from './my-image.png?source'; // Use `asset/source`, similar to `raw-loader` +``` + +You can also use `file` for `asset/resource`, `url` for `asset/inline`, and `raw` for `asset/source`, to make it clear +which loader you're replacing. + +## API + +### `new ScratchWebpackConfigBuilder(options)` + +Creates a new `ScratchWebpackConfigBuilder` instance. + +#### `options` + +Required: + +- `rootPath` (string, required): The root path of the project. This is used to establish defaults for other paths. + +Optional: + +- `distPath` (string, default: `path.join(rootPath, 'dist')`): The path to the output directory. Defaults to `dist` +- `enableReact` (boolean, default: `false`): Whether to enable React support. Adds `.jsx` to the list of extensions + to process, and adjusts Babel settings. +- `libraryName` (string, default: `undefined`): If set, configures a default entry point and output library name. + under the root path. +- `srcPath` (string, default: `path.join(rootPath, 'src')`): The path to the source directory. Defaults to `src` + under the root path. +- `enableTs` (boolean, default: `false`): Whether to enable TypeScript support. +- `sourcePaths` (`Array`, default: `[]`): Additional source roots to process with the default JS/TS rules. +- `tsLoaderOptions` (object, default: ClipCC defaults): Extra options to merge into `ts-loader`. +- `useDefaultTsLoaderOptions` (boolean, default: `true`): Whether to keep ClipCC's default `ts-loader` options. + +### `builder.addWorkspacePackage(options)` + +Registers a workspace package so it can be consumed from source safely. + +- `name` or `alias`: The resolve alias to register. +- `rootPath`: The package root. Defaults `srcPath` to `path.join(rootPath, 'src')`. +- `srcPath`: The package source directory if it is not under `src/`. +- `aliasTarget`: Custom alias target. Defaults to `srcPath`. +- `config`: Existing webpack config for the package. The builder selectively merges `module.rules`, `resolve`, and `snapshot`. +- `moduleRules`: Extra rules for the package. These are scoped to the package source path. +- `includeInDefaultLoaders` (boolean, default: `true`): Whether the builder's JS/TS rules should process this package. + +## Recommended Configuration + +### Package exports + +_The `exports` field in `package.json`_ + +Most `project.json` files specify a `main` entry point, and some specify `browser` as well. Newer versions of Node +support the `exports` field as well. If both are present, `exports` will take precedence. + +For more information about `exports`, see: , especially the "Target +environment" section. + +Unfortunately, plenty of tools don't support `exports` yet, and some that do exhibit some surprising quirks. + +Here's what I currently recommend for a project with only one entry point: + +```json +{ + "main": "./dist/node/foo.js", + "browser": "./dist/web/foo.js", + "exports": { + "webpack": "./src/index.js", + "browser": "./dist/web/foo.js", + "node": "./dist/node/foo.js", + "default": "./src/index.js" + }, +} +``` + +- `main` supports older Node as well as `jest` +- `browser` is present for completeness; I haven't found it strictly necessary +- `exports.webpack` is the entry point for Webpack + - `webpack` will grab the first item under `exports` matching its conditions, including `browser`, so I recommend + listing `exports.webpack` first in the `exports` object + - this allows (for example) `scratch-gui` to build `scratch-vm` from source rather than using the prebuilt version, + resulting in more optimal output and preventing version conflicts due to bundled dependencies +- `exports.default` makes `eslint` happy +- `exports.browser` and `exports.node` prevent `exports`-aware tools from using `exports.default` for all contexts + +Note that using `src/index.js` for the `webpack` and `default` exports means that the NPM package must include `src`. + +### `browserslist` target + +While it could be handy to include `browserslist` configuration in this package, there are tools other than `webpack` +that should use the same `browserslist` configuration. For that reason, I recommend configuring `browserslist` in +your `package.json` file or in a top-level `.browserslistrc` file. + +The Scratch system requirements determine the browsers we should target. That information can be found here: + diff --git a/packages/infra/package.json b/packages/infra/package.json new file mode 100644 index 00000000..0ee06171 --- /dev/null +++ b/packages/infra/package.json @@ -0,0 +1,46 @@ +{ + "name": "clipcc-webpack-configuration", + "version": "3.1.2", + "description": "Shared configuration for ClipCC's use of webpack", + "main": "src/index.cjs", + "type": "commonjs", + "scripts": { + "test": "jest" + }, + "repository": { + "type": "git", + "url": "https://github.com/clipcc/clipcc/packages/infra" + }, + "keywords": [ + "ClipCC", + "webpack" + ], + "author": "Clipteam", + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/clipcc/clipcc/issues" + }, + "homepage": "https://github.com/clipcc/clipcc#readme", + "dependencies": { + "lodash.merge": "^4.6.2", + "webpack-node-externals": "^3.0.0" + }, + "devDependencies": { + "@types/jest": "29.5.14", + "jest": "29.7.0", + "webpack": "5.105.4" + }, + "peerDependencies": { + "@babel/preset-env": "^7.29.0", + "arraybuffer-loader": "^1.0.8", + "autoprefixer": "^9.7.4", + "babel-loader": "^10.1.0", + "css-loader": "6.7.3", + "postcss-import": "^12.0.0", + "postcss-loader": "7.0.2", + "style-loader": "4.0.0", + "ts-loader": "^9.5.4", + "url-loader": "8.0.0", + "webpack": "^5.105.4" + } +} diff --git a/packages/infra/src/index.cjs b/packages/infra/src/index.cjs new file mode 100644 index 00000000..cf9bc32e --- /dev/null +++ b/packages/infra/src/index.cjs @@ -0,0 +1,627 @@ +const path = require('path'); + +const merge = require('lodash.merge'); +const nodeExternals = require('webpack-node-externals'); +const webpack = require('webpack'); +const TerserPlugin = require("terser-webpack-plugin") + +const DEFAULT_CHUNK_FILENAME = 'chunks/[name].[chunkhash].js'; +const DEFAULT_ASSET_FILENAME = 'assets/[name].[hash][ext][query]'; +const DEFAULT_TS_LOADER_OPTIONS = { + transpileOnly: true, + allowTsInNodeModules: true +}; + +/** + * @typedef {import('webpack').Configuration} Configuration + * @typedef {import('webpack').RuleSetRule} RuleSetRule + * @typedef {import('webpack').WebpackPluginFunction} WebpackPluginFunction + * @typedef {import('webpack').WebpackPluginInstance} WebpackPluginInstance +*/ + +/** + * @param {string|URL} [path] A file path as a string or `file://` URL. + * @returns {string|undefined} The file path as a string, or `undefined` if `path` is not a string or `file://` URL. + */ +const toPath = path => { + if (typeof path === 'string') { + return path; + } + if (path?.protocol === 'file:') { + return path.pathname; + } +}; + +/** + * @param {unknown} value + * @returns {Array} + */ +const toArray = value => { + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'undefined') { + return []; + } + return [value]; +}; + +/** + * @param {unknown[]} items + * @returns {unknown[]} + */ +const unique = items => [...new Set(items.filter(item => typeof item !== 'undefined'))]; + +/** + * @param {object} value + * @returns {boolean} + */ +const hasOwnProperties = value => Boolean(value) && Object.keys(value).length > 0; + +class ScratchWebpackConfigBuilder { + /** + * @param {object} options Options for the webpack configuration. + * @param {string|URL} [options.rootPath] The absolute path to the project root. + * @param {string|URL} [options.distPath] The absolute path to build output. Defaults to `dist` under `rootPath`. + * @param {string|URL} [options.publicPath] The public location where the output assets will be located. Defaults to `/`. + * @param {boolean} [options.enableReact] Whether to enable React and JSX support. + * @param {boolean} [options.enableTs] Whether to enable TypeScript support. + * @param {string} [options.libraryName] The name of the library to build. Shorthand for `output.library.name`. + * @param {string|URL} [options.srcPath] The absolute path to the source files. Defaults to `src` under `rootPath`. + * @param {Array} [options.sourcePaths] Additional source paths to process with the default JS/TS rules. + * @param {boolean} [options.shouldSplitChunks] Whether to enable spliting code to chunks. + * @param {RegExp[]} [options.cssModuleExceptions] Optional array of regex rules that exclude matching CSS files from CSS module scoping. + * @param {object} [options.tsLoaderOptions] Additional options for `ts-loader`. + * @param {boolean} [options.useDefaultTsLoaderOptions] Whether to apply ClipCC's default `ts-loader` options. + */ + constructor ({ + distPath, + enableReact, + enableTs, + libraryName, + rootPath, + srcPath, + sourcePaths = [], + publicPath = '/', + shouldSplitChunks, + cssModuleExceptions = [], + tsLoaderOptions, + useDefaultTsLoaderOptions = true + }) { + const isProduction = process.env.NODE_ENV === 'production'; + const mode = isProduction ? 'production' : 'development'; + const resolvedTsLoaderOptions = enableTs ? merge( + {}, + useDefaultTsLoaderOptions ? DEFAULT_TS_LOADER_OPTIONS : {}, + tsLoaderOptions + ) : undefined; + + this._enableReact = Boolean(enableReact); + this._enableTs = Boolean(enableTs); + this._cssModuleExceptions = cssModuleExceptions; + this._libraryName = libraryName; + this._publicPath = publicPath; + this._rootPath = toPath(rootPath) || '.'; // '.' will cause a webpack error since src must be absolute + this._srcPath = toPath(srcPath) ?? path.resolve(this._rootPath, 'src'); + this._distPath = toPath(distPath) ?? path.resolve(this._rootPath, 'dist'); + this._shouldSplitChunks = shouldSplitChunks; + this._sourcePaths = unique([ + this._srcPath, + ...toArray(sourcePaths).map(candidate => toPath(candidate)) + ]); + this._tsLoaderOptions = tsLoaderOptions; + this._useDefaultTsLoaderOptions = useDefaultTsLoaderOptions; + + this._defaultJsRule = { + test: enableReact ? /\.[cm]?jsx?$/ : /\.[cm]?js$/, + include: this._sourcePaths, + loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-env', + ...( + enableReact ? ['@babel/preset-react'] : [] + ) + ] + } + }; + + this._defaultTsRule = enableTs ? { + test: enableReact ? /\.[cm]?tsx?$/ : /\.[cm]?ts$/, + include: this._sourcePaths, + loader: 'ts-loader', + ...(hasOwnProperties(resolvedTsLoaderOptions) ? { + options: resolvedTsLoaderOptions + } : {}) + } : null; + + /** + * @type {Configuration} + */ + this._config = { + mode, + devtool: 'cheap-module-source-map', + entry: libraryName ? { + [libraryName]: path.resolve(this._srcPath, 'index') + } : path.resolve(this._srcPath, 'index'), + optimization: { + minimize: isProduction, + minimizer: [ + new TerserPlugin({ + // Limiting Terser to use only 2 threads. At least for building scratch-gui + // this results in a performance gain (from ~60s to ~36s) on a MacBook with + // M1 Pro and 32GB of RAM and halving the memory usage (from ~11GB at peaks to ~6GB) + parallel: 2 + }) + ], + ...( + shouldSplitChunks ? { + splitChunks: { + chunks: 'all', + filename: DEFAULT_CHUNK_FILENAME, + }, + mergeDuplicateChunks: true + } : {} + ) + }, + output: { + clean: true, + filename: '[name].js', + assetModuleFilename: DEFAULT_ASSET_FILENAME, + chunkFilename: DEFAULT_CHUNK_FILENAME, + path: this._distPath, + // See https://github.com/scratchfoundation/scratch-editor/pull/25/files/9bc537f9bce35ee327b74bd6715d6c5140f73937#r1763073684 + publicPath, + library: { + name: libraryName, + type: 'umd2' + } + }, + resolve: { + extensions: [ + '.mjs', + '.cjs', + ...( + enableReact ? [ + '.mjsx', + '.cjsx', + '.jsx' + ] : [] + ), + ...(enableTs ? ['.ts', '.tsx'] : []), + // webpack supports '...' to include defaults, but eslint does not + '.js', + '.json' + ] + }, + module: { + rules: [ + this._defaultJsRule, + { + // `asset` automatically chooses between exporting a data URI and emitting a separate file. + // Previously achievable by using `url-loader` with asset size limit. + // If file output is chosen, it is saved with the default asset module filename. + resourceQuery: '?asset', + type: 'asset' + }, + { + // `asset/resource` emits a separate file and exports the URL. + // Previously achievable by using `file-loader`. + // Output is saved with the default asset module filename. + resourceQuery: /^\?(resource|file)$/, + type: 'asset/resource' + }, + { + // `asset/inline` exports a data URI of the asset. + // Previously achievable by using `url-loader`. + // Because the file is inlined, there is no filename. + resourceQuery: /^\?(inline|url)$/, + type: 'asset/inline' + }, + { + // `asset/source` exports the source code of the asset. + // Previously achievable by using `raw-loader`. + resourceQuery: /^\?(source|raw)$/, + type: 'asset/source', + generator: { + // This filename seems unused, but if it ever gets used, + // its extension should not match the asset's extension. + filename: DEFAULT_ASSET_FILENAME + '.js' + } + }, + { + resourceQuery: '?arrayBuffer', + type: 'javascript/auto', + use: 'arraybuffer-loader' + }, + { + test: /\.hex$/, + use: [{ + loader: 'url-loader', + options: { + limit: 16 * 1024 + } + }] + }, + ...( + enableReact ? [ + { + test: /\.css$/, + ...(cssModuleExceptions.length > 0 ? { + exclude: cssModuleExceptions + } : {}), + use: [ + { + loader: 'style-loader' + }, + { + loader: 'css-loader', + options: { + modules: { + namedExport: false, + localIdentName: '[name]_[local]_[hash:base64:5]', + exportLocalsConvention: 'camelCase' + }, + importLoaders: 1, + esModule: false + } + }, + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + 'postcss-import', + 'postcss-simple-vars', + 'autoprefixer' + ] + } + } + } + ] + }, + ...(cssModuleExceptions.length > 0 ? [{ + test: cssModuleExceptions, + use: [ + 'style-loader', + 'css-loader', + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + 'postcss-import', + 'autoprefixer' + ] + } + } + } + ] + }] : []) + ] : [] + ), + ...(this._defaultTsRule ? [this._defaultTsRule] : []), + ], + }, + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'] + }) + ] + }; + } + + /** + * @returns {ScratchWebpackConfigBuilder} a copy of the current configuration builder. + */ + clone() { + return new ScratchWebpackConfigBuilder({ + libraryName: this._libraryName, + rootPath: this._rootPath, + srcPath: this._srcPath, + distPath: this._distPath, + sourcePaths: this._sourcePaths, + publicPath: this._publicPath, + enableReact: this._enableReact, + enableTs: this._enableTs, + shouldSplitChunks: this._shouldSplitChunks, + cssModuleExceptions: this._cssModuleExceptions, + tsLoaderOptions: this._tsLoaderOptions, + useDefaultTsLoaderOptions: this._useDefaultTsLoaderOptions + }).merge(this._config); + } + + /** + * @returns {Configuration} a copy of the current configuration object. + */ + get() { + return merge({}, this._config); + } + + /** + * Merge new settings into the current configuration object, overriding existing values. + * @param {Configuration} overrides Settings to apply. + * @returns {this} + */ + merge(overrides) { + merge(this._config, overrides); + return this; + } + + /** + * Append new externals to the current configuration object. + * @param {string[]} externals Externals to add. + * @returns {this} + */ + addExternals(externals) { + this._config.externals = (this._config.externals ?? []).concat(externals); + return this; + } + + /** + * Add another source path to the default JS/TS loader rules. + * @param {string|URL} sourcePath The additional source path. + * @returns {this} + */ + addSourcePath(sourcePath) { + const resolvedSourcePath = toPath(sourcePath); + if (!resolvedSourcePath) { + return this; + } + + this._sourcePaths = unique([...this._sourcePaths, resolvedSourcePath]); + + if (this._defaultJsRule) { + this._defaultJsRule.include = this._sourcePaths; + } + if (this._defaultTsRule) { + this._defaultTsRule.include = this._sourcePaths; + } + + return this; + } + + /** + * Add or override a resolve alias. + * @param {string} alias The alias name. + * @param {string|URL} target The aliased path. + * @returns {this} + */ + addResolveAlias(alias, target) { + const resolvedTarget = toPath(target); + if (!alias || !resolvedTarget) { + return this; + } + + this._config.resolve = this._config.resolve ?? {}; + this._config.resolve.alias = { + ...(this._config.resolve.alias ?? {}), + [alias]: resolvedTarget + }; + + return this; + } + + /** + * Add rules scoped to a specific resource path. + * @param {string|URL} includePath Path to scope the rules to. + * @param {RuleSetRule[]} rules Rules to evaluate within the scope. + * @returns {this} + */ + addScopedModuleRules(includePath, rules) { + const resolvedIncludePath = toPath(includePath); + if (!resolvedIncludePath || !Array.isArray(rules) || rules.length === 0) { + return this; + } + + return this.addModuleRule({ + include: resolvedIncludePath, + rules + }); + } + + /** + * @param {Configuration['resolve']} resolveOptions + */ + _mergeResolveOptions(resolveOptions = {}) { + if (!hasOwnProperties(resolveOptions)) { + return; + } + + this._config.resolve = this._config.resolve ?? {}; + + if (resolveOptions.alias) { + this._config.resolve.alias = merge({}, this._config.resolve.alias ?? {}, resolveOptions.alias); + } + if (resolveOptions.fallback) { + this._config.resolve.fallback = merge({}, this._config.resolve.fallback ?? {}, resolveOptions.fallback); + } + if (resolveOptions.extensions) { + this._config.resolve.extensions = unique([ + ...(this._config.resolve.extensions ?? []), + ...resolveOptions.extensions + ]); + } + + if (Object.prototype.hasOwnProperty.call(resolveOptions, 'symlinks')) { + this._config.resolve.symlinks = resolveOptions.symlinks; + } + + const otherResolveOptions = {...resolveOptions}; + delete otherResolveOptions.alias; + delete otherResolveOptions.extensions; + delete otherResolveOptions.fallback; + delete otherResolveOptions.symlinks; + + merge(this._config.resolve, otherResolveOptions); + } + + /** + * @param {Configuration['snapshot']} snapshotOptions + */ + _mergeSnapshotOptions(snapshotOptions = {}) { + if (!hasOwnProperties(snapshotOptions)) { + return; + } + + this._config.snapshot = this._config.snapshot ?? {}; + + for (const property of ['immutablePaths', 'managedPaths', 'unmanagedPaths']) { + if (snapshotOptions[property]) { + this._config.snapshot[property] = unique([ + ...(this._config.snapshot[property] ?? []), + ...snapshotOptions[property] + ]); + } + } + + const otherSnapshotOptions = {...snapshotOptions}; + delete otherSnapshotOptions.immutablePaths; + delete otherSnapshotOptions.managedPaths; + delete otherSnapshotOptions.unmanagedPaths; + + merge(this._config.snapshot, otherSnapshotOptions); + } + + /** + * Register a workspace package that should be resolved and loaded from source. + * + * Rules imported from the workspace package are wrapped in a parent rule whose + * `include` condition is the package source path, so package-specific loaders do + * not leak into the consumer package. + * + * @param {object} workspacePackage Workspace package settings. + * @param {string} [workspacePackage.name] Package name used as the default alias. + * @param {string} [workspacePackage.alias] Alias name to register. + * @param {string|URL} [workspacePackage.aliasTarget] Path the alias should resolve to. Defaults to `srcPath`. + * @param {Configuration} [workspacePackage.config] Existing package webpack config to merge selectively. + * @param {string|URL} [workspacePackage.rootPath] Package root. Used to infer `srcPath`. + * @param {string|URL} [workspacePackage.srcPath] Package source path. Defaults to `src` under `rootPath`. + * @param {RuleSetRule[]} [workspacePackage.moduleRules] Package-specific module rules. + * @param {Configuration['resolve']} [workspacePackage.resolve] Additional resolve options. + * @param {Configuration['snapshot']} [workspacePackage.snapshot] Additional snapshot options. + * @param {boolean} [workspacePackage.includeInDefaultLoaders] Whether default JS/TS rules should process this package. + * @returns {this} + */ + addWorkspacePackage({ + name, + alias, + aliasTarget, + config, + rootPath, + srcPath, + moduleRules = [], + resolve, + snapshot, + includeInDefaultLoaders = true + }) { + const resolvedRootPath = toPath(rootPath); + const resolvedSrcPath = toPath(srcPath) ?? (resolvedRootPath ? path.resolve(resolvedRootPath, 'src') : undefined); + const resolvedAliasTarget = toPath(aliasTarget) ?? resolvedSrcPath; + const packageAlias = alias ?? name; + const scopedModuleRules = [ + ...(config?.module?.rules ?? []), + ...moduleRules + ]; + + if (!resolvedSrcPath && !resolvedAliasTarget) { + throw new Error('addWorkspacePackage requires rootPath, srcPath, or aliasTarget'); + } + + if (packageAlias && resolvedAliasTarget) { + this.addResolveAlias(packageAlias, resolvedAliasTarget); + } + + if (includeInDefaultLoaders) { + this.addSourcePath(resolvedSrcPath ?? resolvedAliasTarget); + } + + if (scopedModuleRules.length > 0) { + this.addScopedModuleRules(resolvedSrcPath ?? resolvedAliasTarget, scopedModuleRules); + } + + this._mergeResolveOptions(config?.resolve); + this._mergeResolveOptions(resolve); + this._mergeSnapshotOptions(config?.snapshot); + this._mergeSnapshotOptions(snapshot); + + return this; + } + + /** + * Set the target environment for this configuration. + * @param {string} target The target environment, like `node`, `browserslist`, etc. + * @returns {this} + */ + setTarget(target) { + this._config.target = target; + + if (target.startsWith('node')) { + this.merge({ + externalsPresets: {node: true}, + externals: [nodeExternals()], + output: { + path: path.resolve(this._distPath, 'node') + } + }); + } else if (target.startsWith('browserslist')) { + this.merge({ + externalsPresets: {web: true}, + output: { + path: path.resolve(this._distPath, 'web') + } + }); + } + + return this; + } + + /** + * Enable the webpack dev server. Probably only useful for web targets. + * @param {string|number} [port='auto'] The port to listen on, or `'auto'` to use a random port. + * @returns {this} + */ + enableDevServer (port = 'auto') { + return this.merge({ + devServer: { + client: { + overlay: true, + progress: true + }, + port + } + }); + } + + /** + * Add a new rule to `module.rules` in the current configuration object. + * @param {RuleSetRule} rule The rule to add. + * @returns {this} + */ + addModuleRule(rule) { + return this.merge({ + module: { + rules: [ + ...(this._config?.module?.rules ?? []), + rule + ] + } + }); + } + + /** + * Add a new plugin to `plugins` in the current configuration object. + * @param {WebpackPluginInstance|WebpackPluginFunction} plugin The plugin to add. + * @returns {this} + */ + addPlugin(plugin) { + return this.merge({ + plugins: [ + ...(this._config?.plugins ?? []), + plugin + ] + }); + } +} + +module.exports = ScratchWebpackConfigBuilder; diff --git a/packages/infra/test/targets.test.js b/packages/infra/test/targets.test.js new file mode 100644 index 00000000..aec73446 --- /dev/null +++ b/packages/infra/test/targets.test.js @@ -0,0 +1,133 @@ +const path = require('path'); + +const webpack = require('webpack'); + +const ScratchWebpackConfigBuilder = require('../src/index.cjs'); + +const common = { + libraryName: 'test-library', + rootPath: path.resolve(__dirname) +}; + +describe('generating configurations for specific targets', () => { + it('should should generate a valid configuration without a target', () => { + const genericConfig = new ScratchWebpackConfigBuilder(common) + .get(); + expect(genericConfig).not.toHaveProperty('target'); + expect(() => webpack.validate(genericConfig)).not.toThrow(); + }); + + it('should should generate a valid `node` configuration', () => { + const nodeConfig = new ScratchWebpackConfigBuilder(common) + .setTarget('node') + .get(); + expect(nodeConfig).toMatchObject({target: 'node'}); + expect(() => webpack.validate(nodeConfig)).not.toThrow(); + }); + + it('should should generate a valid `browserslist` configuration', () => { + const webConfig = new ScratchWebpackConfigBuilder(common) + .setTarget('browserslist') + .get(); + expect(webConfig).toMatchObject({target: 'browserslist'}); + expect(() => webpack.validate(webConfig)).not.toThrow(); + }); +}); + +describe('TypeScript support', () => { + it('uses a dedicated ts-loader rule with ClipCC defaults', () => { + const externalSourcePath = path.resolve(__dirname, 'external-src'); + const config = new ScratchWebpackConfigBuilder({ + ...common, + enableTs: true, + sourcePaths: [externalSourcePath] + }).get(); + + const jsRule = config.module.rules.find(rule => rule.loader === 'babel-loader'); + const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); + + expect(jsRule.test.test('example.js')).toBe(true); + expect(jsRule.test.test('example.ts')).toBe(false); + expect(tsRule).toMatchObject({ + options: { + transpileOnly: true, + allowTsInNodeModules: true + } + }); + expect(tsRule.include).toEqual(expect.arrayContaining([ + path.resolve(__dirname, 'src'), + externalSourcePath + ])); + expect(() => webpack.validate(config)).not.toThrow(); + }); + + it('lets callers override the default ts-loader options', () => { + const config = new ScratchWebpackConfigBuilder({ + ...common, + enableTs: true, + tsLoaderOptions: { + transpileOnly: false, + projectReferences: true + }, + useDefaultTsLoaderOptions: false + }).get(); + + const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); + + expect(tsRule.options).toEqual({ + transpileOnly: false, + projectReferences: true + }); + expect(() => webpack.validate(config)).not.toThrow(); + }); +}); + +describe('workspace package support', () => { + it('scopes package-specific rules to the workspace package source path', () => { + const blockRootPath = path.resolve(__dirname, '../block'); + const blockSrcPath = path.resolve(blockRootPath, 'src'); + const managedPathPattern = /^(.+?[\\/]node_modules[\\/](?!clipcc-block).+?)[\\/]/; + const config = new ScratchWebpackConfigBuilder({ + ...common, + enableReact: true, + enableTs: true + }) + .addWorkspacePackage({ + name: 'clipcc-block', + rootPath: blockRootPath, + config: { + module: { + rules: [{ + test: /\.css$/, + use: 'raw-loader' + }] + }, + resolve: { + alias: { + 'clipcc-block/msg': path.resolve(blockSrcPath, 'msg') + }, + extensions: ['.block.css'], + symlinks: false + }, + snapshot: { + managedPaths: [managedPathPattern] + } + } + }) + .get(); + + const jsRule = config.module.rules.find(rule => rule.loader === 'babel-loader'); + const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); + const scopedRule = config.module.rules.find(rule => rule.include === blockSrcPath && Array.isArray(rule.rules)); + + expect(config.resolve.alias['clipcc-block']).toBe(blockSrcPath); + expect(config.resolve.alias['clipcc-block/msg']).toBe(path.resolve(blockSrcPath, 'msg')); + expect(config.resolve.extensions).toEqual(expect.arrayContaining(['.block.css'])); + expect(config.resolve.symlinks).toBe(false); + expect(config.snapshot.managedPaths).toContain(managedPathPattern); + expect(jsRule.include).toEqual(expect.arrayContaining([blockSrcPath])); + expect(tsRule.include).toEqual(expect.arrayContaining([blockSrcPath])); + expect(scopedRule.rules).toEqual([{test: /\.css$/, use: 'raw-loader'}]); + expect(() => webpack.validate(config)).not.toThrow(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 08e15c33..8cd5d65c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19686,6 +19686,11 @@ webpack-merge@^6.0.1: flat "^5.0.2" wildcard "^2.0.1" +webpack-node-externals@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz#1a3407c158d547a9feb4229a9e3385b7b60c9917" + integrity sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ== + webpack-sources@^3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" From edcbb9f415c93aa85a90116d0d7f49768421f247 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 11 Mar 2026 16:54:58 +0800 Subject: [PATCH 02/10] :wrench: chore: make vm/block to use our config builder Signed-off-by: SimonShiki --- package.json | 2 +- packages/block/eslint.config.mjs | 1 + packages/block/webpack.config.js | 145 +++---- packages/block/webpack.manifest.js | 24 ++ packages/gui/webpack.manifest.js | 24 ++ packages/infra/package.json | 17 +- packages/infra/src/index.cjs | 627 ----------------------------- packages/infra/src/index.js | 521 ++++++++++++++++++++++++ packages/infra/tsconfig.dts.json | 27 ++ packages/vm/webpack.config.js | 217 ++++------ packages/vm/webpack.manifest.js | 34 ++ 11 files changed, 778 insertions(+), 861 deletions(-) create mode 100644 packages/block/webpack.manifest.js create mode 100644 packages/gui/webpack.manifest.js delete mode 100644 packages/infra/src/index.cjs create mode 100644 packages/infra/src/index.js create mode 100644 packages/infra/tsconfig.dts.json create mode 100644 packages/vm/webpack.manifest.js diff --git a/package.json b/package.json index 52062275..59e1195a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "start": "yarn gui start", "prepare": "husky", "build:dist": "cross-env NODE_ENV=production yarn build:full", - "build:full": "yarn l10n build && yarn audio build && yarn storage build && yarn render build && yarn block build && yarn vm build && yarn paint build && node packages/gui/scripts/prepublish.mjs && yarn gui build", + "build:full": "yarn infra build && yarn l10n build && yarn audio build && yarn storage build && yarn render build && yarn block build && yarn vm build && yarn paint build && node packages/gui/scripts/prepublish.mjs && yarn gui build", "build": "yarn block build && yarn gui build", "test": "yarn gui test:unit && yarn block test && yarn vm test", "performance": "yarn vm performance", diff --git a/packages/block/eslint.config.mjs b/packages/block/eslint.config.mjs index 2700ff88..e6f677ec 100644 --- a/packages/block/eslint.config.mjs +++ b/packages/block/eslint.config.mjs @@ -62,6 +62,7 @@ export default [ 'scripts/**/*.js', 'tests/**/*.js', 'webpack.config.js', + 'webpack.manifest.js', 'jest.config.js' ], languageOptions: { diff --git a/packages/block/webpack.config.js b/packages/block/webpack.config.js index 12bf3b29..11b884ee 100644 --- a/packages/block/webpack.config.js +++ b/packages/block/webpack.config.js @@ -1,90 +1,69 @@ const path = require('path'); -const defaultsDeep = require('lodash.defaultsdeep'); const CopyWebpackPlugin = require('copy-webpack-plugin'); +const WebpackConfigBuilder = require('../infra'); +const manifest = require('./webpack.manifest'); -const baseConfig = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: process.env.NODE_ENV === 'production' ? false : 'eval-cheap-module-source-map', - entry: './src/index.ts', - output: { - library: 'ScratchBlocks', - filename: '[name].js' - }, - resolve: { - extensions: ['.ts', '.js'] - }, - module: { - rules: [{ - test: /\.css$/, - use: 'raw-loader', - include: path.resolve(__dirname, 'src') - }, { - test: /\.ts$/, - use: 'ts-loader', - exclude: /node_modules/ - }, { - test: /_compressed\.js$/, - enforce: 'pre', - use: 'source-map-loader', - include: /blockly/ - }] - }, - ignoreWarnings: [/Failed to parse source map/] + +const createConfig = (overrideManifest) => { + const config = new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest + }).get(); + + config.devtool = process.env.NODE_ENV === 'production' ? false : 'eval-cheap-module-source-map'; + config.ignoreWarnings = [/Failed to parse source map/]; + + return config; +}; + +// Playground +const playground = createConfig({ + distPath: './build', + playground: 8071, + target: 'web', + plugins: [ + new CopyWebpackPlugin({ + patterns: [{ + from: path.resolve(require.resolve('blockly'), '../media'), + to: 'media' + }, { + from: 'media', + to: 'media', + force: true + }, { + from: 'tests/playground.html', + to: 'index.html' + }, { + from: 'tests/toolbox.json', + to: 'toolbox.json' + }, { + from: 'msg/messages.js' + }] + }) + ] +}); +playground.devServer.static = false; + +// Node-compatible +const node = createConfig({ + distPath: './dist/node', + target: 'node' +}); + +node.externals = { + bufferutil: true, + 'utf-8-validate': true, + canvas: true }; +// Web-comptible +const web = createConfig({ + distPath: './dist/web', + target: 'web' +}); + module.exports = [ - // Playground - defaultsDeep({}, baseConfig, { - target: 'web', - devServer: { - static: false, - host: '0.0.0.0', - port: process.env.PORT || 8071 - }, - output: { - libraryTarget: 'umd', - path: path.resolve(__dirname, 'build') - }, - plugins: [ - new CopyWebpackPlugin({ - patterns: [{ - from: path.resolve(require.resolve('blockly'), '../media'), - to: 'media' - }, { - from: 'media', - to: 'media', - force: true - }, { - from: 'tests/playground.html', - to: 'index.html' - }, { - from: 'tests/toolbox.json', - to: 'toolbox.json' - }, { - from: 'msg/messages.js' - }] - }) - ] - }), - // Node-compatible - defaultsDeep({}, baseConfig, { - target: 'node', - output: { - libraryTarget: 'commonjs2', - path: path.resolve(__dirname, 'dist', 'node') - }, - externals: { - bufferutil: true, - 'utf-8-validate': true, - canvas: true - } - }), - // Web-comptible - defaultsDeep({}, baseConfig, { - target: 'web', - output: { - libraryTarget: 'umd', - path: path.resolve(__dirname, 'dist', 'web') - } - }) + playground, + node, + web ]; diff --git a/packages/block/webpack.manifest.js b/packages/block/webpack.manifest.js new file mode 100644 index 00000000..5649be47 --- /dev/null +++ b/packages/block/webpack.manifest.js @@ -0,0 +1,24 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const manifest = { + libraryName: 'ScratchBlocks', + rootPath: __dirname, + entry: './src/index.ts', + enableTs: true, + rules: [{ + test: /\.css$/, + use: 'raw-loader', + include: 'src' + }, { + test: /_compressed\.js$/, + enforce: 'pre', + use: 'source-map-loader', + include: /blockly/ + }] +}; + +module.exports = manifest; diff --git a/packages/gui/webpack.manifest.js b/packages/gui/webpack.manifest.js new file mode 100644 index 00000000..652b4630 --- /dev/null +++ b/packages/gui/webpack.manifest.js @@ -0,0 +1,24 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const base = { + libraryName: 'GUI', + playground: 8601, + rootPath: __dirname, + entry: './src/index.js', + + enableReact: true, + enableTs: true, + + workspacePackages: [ + 'clipcc-vm', + 'clipcc-block', + 'clipcc-paint', + 'clipcc-render' + ] +}; + +module.exports = base; diff --git a/packages/infra/package.json b/packages/infra/package.json index 0ee06171..3255f64b 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -2,14 +2,17 @@ "name": "clipcc-webpack-configuration", "version": "3.1.2", "description": "Shared configuration for ClipCC's use of webpack", - "main": "src/index.cjs", + "main": "src/index.js", + "types": "./dist/types/index.d.ts", "type": "commonjs", "scripts": { + "prepublish": "yarn build", + "build": "tsc --project ./tsconfig.dts.json", "test": "jest" }, "repository": { "type": "git", - "url": "https://github.com/clipcc/clipcc/packages/infra" + "url": "https://github.com/Clipteam/clipcc/packages/infra" }, "keywords": [ "ClipCC", @@ -18,9 +21,9 @@ "author": "Clipteam", "license": "BSD-3-Clause", "bugs": { - "url": "https://github.com/clipcc/clipcc/issues" + "url": "https://github.com/Clipteam/clipcc/issues" }, - "homepage": "https://github.com/clipcc/clipcc#readme", + "homepage": "https://github.com/Clipteamclipcc#readme", "dependencies": { "lodash.merge": "^4.6.2", "webpack-node-externals": "^3.0.0" @@ -28,7 +31,8 @@ "devDependencies": { "@types/jest": "29.5.14", "jest": "29.7.0", - "webpack": "5.105.4" + "typescript": "^5.9.3", + "webpack": "^5.105.4" }, "peerDependencies": { "@babel/preset-env": "^7.29.0", @@ -40,7 +44,6 @@ "postcss-loader": "7.0.2", "style-loader": "4.0.0", "ts-loader": "^9.5.4", - "url-loader": "8.0.0", - "webpack": "^5.105.4" + "url-loader": "8.0.0" } } diff --git a/packages/infra/src/index.cjs b/packages/infra/src/index.cjs deleted file mode 100644 index cf9bc32e..00000000 --- a/packages/infra/src/index.cjs +++ /dev/null @@ -1,627 +0,0 @@ -const path = require('path'); - -const merge = require('lodash.merge'); -const nodeExternals = require('webpack-node-externals'); -const webpack = require('webpack'); -const TerserPlugin = require("terser-webpack-plugin") - -const DEFAULT_CHUNK_FILENAME = 'chunks/[name].[chunkhash].js'; -const DEFAULT_ASSET_FILENAME = 'assets/[name].[hash][ext][query]'; -const DEFAULT_TS_LOADER_OPTIONS = { - transpileOnly: true, - allowTsInNodeModules: true -}; - -/** - * @typedef {import('webpack').Configuration} Configuration - * @typedef {import('webpack').RuleSetRule} RuleSetRule - * @typedef {import('webpack').WebpackPluginFunction} WebpackPluginFunction - * @typedef {import('webpack').WebpackPluginInstance} WebpackPluginInstance -*/ - -/** - * @param {string|URL} [path] A file path as a string or `file://` URL. - * @returns {string|undefined} The file path as a string, or `undefined` if `path` is not a string or `file://` URL. - */ -const toPath = path => { - if (typeof path === 'string') { - return path; - } - if (path?.protocol === 'file:') { - return path.pathname; - } -}; - -/** - * @param {unknown} value - * @returns {Array} - */ -const toArray = value => { - if (Array.isArray(value)) { - return value; - } - if (typeof value === 'undefined') { - return []; - } - return [value]; -}; - -/** - * @param {unknown[]} items - * @returns {unknown[]} - */ -const unique = items => [...new Set(items.filter(item => typeof item !== 'undefined'))]; - -/** - * @param {object} value - * @returns {boolean} - */ -const hasOwnProperties = value => Boolean(value) && Object.keys(value).length > 0; - -class ScratchWebpackConfigBuilder { - /** - * @param {object} options Options for the webpack configuration. - * @param {string|URL} [options.rootPath] The absolute path to the project root. - * @param {string|URL} [options.distPath] The absolute path to build output. Defaults to `dist` under `rootPath`. - * @param {string|URL} [options.publicPath] The public location where the output assets will be located. Defaults to `/`. - * @param {boolean} [options.enableReact] Whether to enable React and JSX support. - * @param {boolean} [options.enableTs] Whether to enable TypeScript support. - * @param {string} [options.libraryName] The name of the library to build. Shorthand for `output.library.name`. - * @param {string|URL} [options.srcPath] The absolute path to the source files. Defaults to `src` under `rootPath`. - * @param {Array} [options.sourcePaths] Additional source paths to process with the default JS/TS rules. - * @param {boolean} [options.shouldSplitChunks] Whether to enable spliting code to chunks. - * @param {RegExp[]} [options.cssModuleExceptions] Optional array of regex rules that exclude matching CSS files from CSS module scoping. - * @param {object} [options.tsLoaderOptions] Additional options for `ts-loader`. - * @param {boolean} [options.useDefaultTsLoaderOptions] Whether to apply ClipCC's default `ts-loader` options. - */ - constructor ({ - distPath, - enableReact, - enableTs, - libraryName, - rootPath, - srcPath, - sourcePaths = [], - publicPath = '/', - shouldSplitChunks, - cssModuleExceptions = [], - tsLoaderOptions, - useDefaultTsLoaderOptions = true - }) { - const isProduction = process.env.NODE_ENV === 'production'; - const mode = isProduction ? 'production' : 'development'; - const resolvedTsLoaderOptions = enableTs ? merge( - {}, - useDefaultTsLoaderOptions ? DEFAULT_TS_LOADER_OPTIONS : {}, - tsLoaderOptions - ) : undefined; - - this._enableReact = Boolean(enableReact); - this._enableTs = Boolean(enableTs); - this._cssModuleExceptions = cssModuleExceptions; - this._libraryName = libraryName; - this._publicPath = publicPath; - this._rootPath = toPath(rootPath) || '.'; // '.' will cause a webpack error since src must be absolute - this._srcPath = toPath(srcPath) ?? path.resolve(this._rootPath, 'src'); - this._distPath = toPath(distPath) ?? path.resolve(this._rootPath, 'dist'); - this._shouldSplitChunks = shouldSplitChunks; - this._sourcePaths = unique([ - this._srcPath, - ...toArray(sourcePaths).map(candidate => toPath(candidate)) - ]); - this._tsLoaderOptions = tsLoaderOptions; - this._useDefaultTsLoaderOptions = useDefaultTsLoaderOptions; - - this._defaultJsRule = { - test: enableReact ? /\.[cm]?jsx?$/ : /\.[cm]?js$/, - include: this._sourcePaths, - loader: 'babel-loader', - options: { - presets: [ - '@babel/preset-env', - ...( - enableReact ? ['@babel/preset-react'] : [] - ) - ] - } - }; - - this._defaultTsRule = enableTs ? { - test: enableReact ? /\.[cm]?tsx?$/ : /\.[cm]?ts$/, - include: this._sourcePaths, - loader: 'ts-loader', - ...(hasOwnProperties(resolvedTsLoaderOptions) ? { - options: resolvedTsLoaderOptions - } : {}) - } : null; - - /** - * @type {Configuration} - */ - this._config = { - mode, - devtool: 'cheap-module-source-map', - entry: libraryName ? { - [libraryName]: path.resolve(this._srcPath, 'index') - } : path.resolve(this._srcPath, 'index'), - optimization: { - minimize: isProduction, - minimizer: [ - new TerserPlugin({ - // Limiting Terser to use only 2 threads. At least for building scratch-gui - // this results in a performance gain (from ~60s to ~36s) on a MacBook with - // M1 Pro and 32GB of RAM and halving the memory usage (from ~11GB at peaks to ~6GB) - parallel: 2 - }) - ], - ...( - shouldSplitChunks ? { - splitChunks: { - chunks: 'all', - filename: DEFAULT_CHUNK_FILENAME, - }, - mergeDuplicateChunks: true - } : {} - ) - }, - output: { - clean: true, - filename: '[name].js', - assetModuleFilename: DEFAULT_ASSET_FILENAME, - chunkFilename: DEFAULT_CHUNK_FILENAME, - path: this._distPath, - // See https://github.com/scratchfoundation/scratch-editor/pull/25/files/9bc537f9bce35ee327b74bd6715d6c5140f73937#r1763073684 - publicPath, - library: { - name: libraryName, - type: 'umd2' - } - }, - resolve: { - extensions: [ - '.mjs', - '.cjs', - ...( - enableReact ? [ - '.mjsx', - '.cjsx', - '.jsx' - ] : [] - ), - ...(enableTs ? ['.ts', '.tsx'] : []), - // webpack supports '...' to include defaults, but eslint does not - '.js', - '.json' - ] - }, - module: { - rules: [ - this._defaultJsRule, - { - // `asset` automatically chooses between exporting a data URI and emitting a separate file. - // Previously achievable by using `url-loader` with asset size limit. - // If file output is chosen, it is saved with the default asset module filename. - resourceQuery: '?asset', - type: 'asset' - }, - { - // `asset/resource` emits a separate file and exports the URL. - // Previously achievable by using `file-loader`. - // Output is saved with the default asset module filename. - resourceQuery: /^\?(resource|file)$/, - type: 'asset/resource' - }, - { - // `asset/inline` exports a data URI of the asset. - // Previously achievable by using `url-loader`. - // Because the file is inlined, there is no filename. - resourceQuery: /^\?(inline|url)$/, - type: 'asset/inline' - }, - { - // `asset/source` exports the source code of the asset. - // Previously achievable by using `raw-loader`. - resourceQuery: /^\?(source|raw)$/, - type: 'asset/source', - generator: { - // This filename seems unused, but if it ever gets used, - // its extension should not match the asset's extension. - filename: DEFAULT_ASSET_FILENAME + '.js' - } - }, - { - resourceQuery: '?arrayBuffer', - type: 'javascript/auto', - use: 'arraybuffer-loader' - }, - { - test: /\.hex$/, - use: [{ - loader: 'url-loader', - options: { - limit: 16 * 1024 - } - }] - }, - ...( - enableReact ? [ - { - test: /\.css$/, - ...(cssModuleExceptions.length > 0 ? { - exclude: cssModuleExceptions - } : {}), - use: [ - { - loader: 'style-loader' - }, - { - loader: 'css-loader', - options: { - modules: { - namedExport: false, - localIdentName: '[name]_[local]_[hash:base64:5]', - exportLocalsConvention: 'camelCase' - }, - importLoaders: 1, - esModule: false - } - }, - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - 'postcss-import', - 'postcss-simple-vars', - 'autoprefixer' - ] - } - } - } - ] - }, - ...(cssModuleExceptions.length > 0 ? [{ - test: cssModuleExceptions, - use: [ - 'style-loader', - 'css-loader', - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - 'postcss-import', - 'autoprefixer' - ] - } - } - } - ] - }] : []) - ] : [] - ), - ...(this._defaultTsRule ? [this._defaultTsRule] : []), - ], - }, - plugins: [ - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'] - }) - ] - }; - } - - /** - * @returns {ScratchWebpackConfigBuilder} a copy of the current configuration builder. - */ - clone() { - return new ScratchWebpackConfigBuilder({ - libraryName: this._libraryName, - rootPath: this._rootPath, - srcPath: this._srcPath, - distPath: this._distPath, - sourcePaths: this._sourcePaths, - publicPath: this._publicPath, - enableReact: this._enableReact, - enableTs: this._enableTs, - shouldSplitChunks: this._shouldSplitChunks, - cssModuleExceptions: this._cssModuleExceptions, - tsLoaderOptions: this._tsLoaderOptions, - useDefaultTsLoaderOptions: this._useDefaultTsLoaderOptions - }).merge(this._config); - } - - /** - * @returns {Configuration} a copy of the current configuration object. - */ - get() { - return merge({}, this._config); - } - - /** - * Merge new settings into the current configuration object, overriding existing values. - * @param {Configuration} overrides Settings to apply. - * @returns {this} - */ - merge(overrides) { - merge(this._config, overrides); - return this; - } - - /** - * Append new externals to the current configuration object. - * @param {string[]} externals Externals to add. - * @returns {this} - */ - addExternals(externals) { - this._config.externals = (this._config.externals ?? []).concat(externals); - return this; - } - - /** - * Add another source path to the default JS/TS loader rules. - * @param {string|URL} sourcePath The additional source path. - * @returns {this} - */ - addSourcePath(sourcePath) { - const resolvedSourcePath = toPath(sourcePath); - if (!resolvedSourcePath) { - return this; - } - - this._sourcePaths = unique([...this._sourcePaths, resolvedSourcePath]); - - if (this._defaultJsRule) { - this._defaultJsRule.include = this._sourcePaths; - } - if (this._defaultTsRule) { - this._defaultTsRule.include = this._sourcePaths; - } - - return this; - } - - /** - * Add or override a resolve alias. - * @param {string} alias The alias name. - * @param {string|URL} target The aliased path. - * @returns {this} - */ - addResolveAlias(alias, target) { - const resolvedTarget = toPath(target); - if (!alias || !resolvedTarget) { - return this; - } - - this._config.resolve = this._config.resolve ?? {}; - this._config.resolve.alias = { - ...(this._config.resolve.alias ?? {}), - [alias]: resolvedTarget - }; - - return this; - } - - /** - * Add rules scoped to a specific resource path. - * @param {string|URL} includePath Path to scope the rules to. - * @param {RuleSetRule[]} rules Rules to evaluate within the scope. - * @returns {this} - */ - addScopedModuleRules(includePath, rules) { - const resolvedIncludePath = toPath(includePath); - if (!resolvedIncludePath || !Array.isArray(rules) || rules.length === 0) { - return this; - } - - return this.addModuleRule({ - include: resolvedIncludePath, - rules - }); - } - - /** - * @param {Configuration['resolve']} resolveOptions - */ - _mergeResolveOptions(resolveOptions = {}) { - if (!hasOwnProperties(resolveOptions)) { - return; - } - - this._config.resolve = this._config.resolve ?? {}; - - if (resolveOptions.alias) { - this._config.resolve.alias = merge({}, this._config.resolve.alias ?? {}, resolveOptions.alias); - } - if (resolveOptions.fallback) { - this._config.resolve.fallback = merge({}, this._config.resolve.fallback ?? {}, resolveOptions.fallback); - } - if (resolveOptions.extensions) { - this._config.resolve.extensions = unique([ - ...(this._config.resolve.extensions ?? []), - ...resolveOptions.extensions - ]); - } - - if (Object.prototype.hasOwnProperty.call(resolveOptions, 'symlinks')) { - this._config.resolve.symlinks = resolveOptions.symlinks; - } - - const otherResolveOptions = {...resolveOptions}; - delete otherResolveOptions.alias; - delete otherResolveOptions.extensions; - delete otherResolveOptions.fallback; - delete otherResolveOptions.symlinks; - - merge(this._config.resolve, otherResolveOptions); - } - - /** - * @param {Configuration['snapshot']} snapshotOptions - */ - _mergeSnapshotOptions(snapshotOptions = {}) { - if (!hasOwnProperties(snapshotOptions)) { - return; - } - - this._config.snapshot = this._config.snapshot ?? {}; - - for (const property of ['immutablePaths', 'managedPaths', 'unmanagedPaths']) { - if (snapshotOptions[property]) { - this._config.snapshot[property] = unique([ - ...(this._config.snapshot[property] ?? []), - ...snapshotOptions[property] - ]); - } - } - - const otherSnapshotOptions = {...snapshotOptions}; - delete otherSnapshotOptions.immutablePaths; - delete otherSnapshotOptions.managedPaths; - delete otherSnapshotOptions.unmanagedPaths; - - merge(this._config.snapshot, otherSnapshotOptions); - } - - /** - * Register a workspace package that should be resolved and loaded from source. - * - * Rules imported from the workspace package are wrapped in a parent rule whose - * `include` condition is the package source path, so package-specific loaders do - * not leak into the consumer package. - * - * @param {object} workspacePackage Workspace package settings. - * @param {string} [workspacePackage.name] Package name used as the default alias. - * @param {string} [workspacePackage.alias] Alias name to register. - * @param {string|URL} [workspacePackage.aliasTarget] Path the alias should resolve to. Defaults to `srcPath`. - * @param {Configuration} [workspacePackage.config] Existing package webpack config to merge selectively. - * @param {string|URL} [workspacePackage.rootPath] Package root. Used to infer `srcPath`. - * @param {string|URL} [workspacePackage.srcPath] Package source path. Defaults to `src` under `rootPath`. - * @param {RuleSetRule[]} [workspacePackage.moduleRules] Package-specific module rules. - * @param {Configuration['resolve']} [workspacePackage.resolve] Additional resolve options. - * @param {Configuration['snapshot']} [workspacePackage.snapshot] Additional snapshot options. - * @param {boolean} [workspacePackage.includeInDefaultLoaders] Whether default JS/TS rules should process this package. - * @returns {this} - */ - addWorkspacePackage({ - name, - alias, - aliasTarget, - config, - rootPath, - srcPath, - moduleRules = [], - resolve, - snapshot, - includeInDefaultLoaders = true - }) { - const resolvedRootPath = toPath(rootPath); - const resolvedSrcPath = toPath(srcPath) ?? (resolvedRootPath ? path.resolve(resolvedRootPath, 'src') : undefined); - const resolvedAliasTarget = toPath(aliasTarget) ?? resolvedSrcPath; - const packageAlias = alias ?? name; - const scopedModuleRules = [ - ...(config?.module?.rules ?? []), - ...moduleRules - ]; - - if (!resolvedSrcPath && !resolvedAliasTarget) { - throw new Error('addWorkspacePackage requires rootPath, srcPath, or aliasTarget'); - } - - if (packageAlias && resolvedAliasTarget) { - this.addResolveAlias(packageAlias, resolvedAliasTarget); - } - - if (includeInDefaultLoaders) { - this.addSourcePath(resolvedSrcPath ?? resolvedAliasTarget); - } - - if (scopedModuleRules.length > 0) { - this.addScopedModuleRules(resolvedSrcPath ?? resolvedAliasTarget, scopedModuleRules); - } - - this._mergeResolveOptions(config?.resolve); - this._mergeResolveOptions(resolve); - this._mergeSnapshotOptions(config?.snapshot); - this._mergeSnapshotOptions(snapshot); - - return this; - } - - /** - * Set the target environment for this configuration. - * @param {string} target The target environment, like `node`, `browserslist`, etc. - * @returns {this} - */ - setTarget(target) { - this._config.target = target; - - if (target.startsWith('node')) { - this.merge({ - externalsPresets: {node: true}, - externals: [nodeExternals()], - output: { - path: path.resolve(this._distPath, 'node') - } - }); - } else if (target.startsWith('browserslist')) { - this.merge({ - externalsPresets: {web: true}, - output: { - path: path.resolve(this._distPath, 'web') - } - }); - } - - return this; - } - - /** - * Enable the webpack dev server. Probably only useful for web targets. - * @param {string|number} [port='auto'] The port to listen on, or `'auto'` to use a random port. - * @returns {this} - */ - enableDevServer (port = 'auto') { - return this.merge({ - devServer: { - client: { - overlay: true, - progress: true - }, - port - } - }); - } - - /** - * Add a new rule to `module.rules` in the current configuration object. - * @param {RuleSetRule} rule The rule to add. - * @returns {this} - */ - addModuleRule(rule) { - return this.merge({ - module: { - rules: [ - ...(this._config?.module?.rules ?? []), - rule - ] - } - }); - } - - /** - * Add a new plugin to `plugins` in the current configuration object. - * @param {WebpackPluginInstance|WebpackPluginFunction} plugin The plugin to add. - * @returns {this} - */ - addPlugin(plugin) { - return this.merge({ - plugins: [ - ...(this._config?.plugins ?? []), - plugin - ] - }); - } -} - -module.exports = ScratchWebpackConfigBuilder; diff --git a/packages/infra/src/index.js b/packages/infra/src/index.js new file mode 100644 index 00000000..0a4ee030 --- /dev/null +++ b/packages/infra/src/index.js @@ -0,0 +1,521 @@ +// @ts-check + +const path = require('path'); +const fs = require('fs'); + +const TerserPlugin = require('terser-webpack-plugin'); +const nodeExternals = require('webpack-node-externals'); + +/** @typedef {import('webpack').Configuration} Configuration */ +/** @typedef {import('webpack').RuleSetRule} RuleSetRule */ +/** @typedef {NonNullable} SnapshotConfig */ +/** + * @typedef {Configuration & { + * devServer?: { + * static: string, + * host: string, + * port: string | number + * } + * }} ConfigWithDevServer + */ +/** + * @typedef {RuleSetRule & { + * include?: RuleSetRule['include'], + * exclude?: RuleSetRule['exclude'], + * rules?: ManifestRule[], + * oneOf?: ManifestRule[] + * }} ManifestRule + */ +/** + * @typedef {{ + * entry: string, + * libraryName: string, + * target?: Configuration['target'], + * rootPath: string, + * srcPath?: string, + * distPath?: string, + * publicPath?: string, + * sourcePaths?: string[], + * enableReact?: boolean, + * enableTs?: boolean, + * shouldSplitChunks?: boolean, + * rules?: ManifestRule[], + * plugins?: Configuration['plugins'], + * alias?: Record, + * snapshot?: SnapshotConfig, + * playground?: boolean | number, + * workspacePackages?: string[] + * }} WebpackManifest + */ + +const DEFAULT_CHUNK_FILENAME = 'chunks/[name].js'; +const DEFAULT_TS_LOADER_OPTIONS = { + transpileOnly: true, + allowTsInNodeModules: true +}; + +/** + * @param {Buffer} content + * @returns {string} + */ +const createHexDataUrl = content => `data:text/plain;base64,${content.toString('base64')}`; + +/** + * @template T + * @param {T | T[]=} value + * @returns {T[]} + */ +const toArray = value => { + if (Array.isArray(value)) return value; + if (typeof value === 'undefined') return []; + return [value]; +}; + +/** + * @template T + * @param {Array} values + * @returns {T[]} + */ +const unique = values => { + const filteredValues = /** @type {T[]} */ (values.filter(value => typeof value !== 'undefined')); + return Array.from(new Set(filteredValues)); +}; + +/** + * @param {string} rootPath + * @param {ManifestRule[]=} rules + * @returns {ManifestRule[]} + */ +const normalizeChildRules = (rootPath, rules) => (rules ?? []) + .filter(childRule => Boolean(childRule) && typeof childRule === 'object') + .map(childRule => normalizeRule(rootPath, /** @type {ManifestRule} */ (childRule))); + +/** + * @param {string} value + * @returns {boolean} + */ +const isRelativePath = value => value.startsWith('.') || value.startsWith('..'); + +/** + * @param {string} rootPath + * @param {string} value + * @returns {string} + */ +const maybeResolvePath = (rootPath, value) => { + if (path.isAbsolute(value) || isRelativePath(value)) { + return path.resolve(rootPath, value); + } + + const resolvedPath = path.resolve(rootPath, value); + return fs.existsSync(resolvedPath) ? resolvedPath : value; +}; + +/** + * @param {string} rootPath + * @param {RuleSetRule['include'] | RuleSetRule['exclude']=} condition + * @returns {RuleSetRule['include'] | RuleSetRule['exclude'] | undefined} + */ +const normalizeRuleCondition = (rootPath, condition) => { + if (Array.isArray(condition)) { + return condition.map(entry => typeof entry === 'string' ? path.resolve(rootPath, entry) : entry); + } + + if (typeof condition === 'string') { + return path.resolve(rootPath, condition); + } + + return condition; +}; + +/** + * @param {string} rootPath + * @param {ManifestRule} rule + * @returns {ManifestRule} + */ +const normalizeRule = (rootPath, rule) => ({ + ...rule, + include: normalizeRuleCondition(rootPath, rule.include), + exclude: normalizeRuleCondition(rootPath, rule.exclude), + ...(Array.isArray(rule.rules) ? { + rules: normalizeChildRules(rootPath, rule.rules) + } : {}), + ...(Array.isArray(rule.oneOf) ? { + oneOf: normalizeChildRules(rootPath, rule.oneOf) + } : {}) +}); + +/** + * @param {string} rootPath + * @param {Configuration['snapshot']=} snapshot + * @returns {Configuration['snapshot'] | undefined} + */ +const normalizeSnapshot = (rootPath, snapshot) => { + if (!snapshot) return undefined; + + /** + * @param {Array=} entries + * @returns {Array | undefined} + */ + const normalizePathEntries = entries => { + if (!entries) return undefined; + return unique(entries.map(entry => typeof entry === 'string' ? path.resolve(rootPath, entry) : entry)); + }; + + return { + ...snapshot, + immutablePaths: normalizePathEntries(snapshot.immutablePaths), + managedPaths: normalizePathEntries(snapshot.managedPaths), + unmanagedPaths: normalizePathEntries(snapshot.unmanagedPaths) + }; +}; + +/** + * @param {SnapshotConfig=} currentSnapshot + * @param {SnapshotConfig=} nextSnapshot + * @returns {SnapshotConfig} + */ +const mergeSnapshot = (currentSnapshot, nextSnapshot) => { + if (!currentSnapshot && !nextSnapshot) return {}; + if (!currentSnapshot) return nextSnapshot ?? {}; + if (!nextSnapshot) return currentSnapshot; + + /** + * @param {Array=} currentEntries + * @param {Array=} nextEntries + * @returns {Array | undefined} + */ + const mergeEntries = (currentEntries, nextEntries) => { + const merged = unique([...(currentEntries ?? []), ...(nextEntries ?? [])]); + return merged.length > 0 ? merged : undefined; + }; + + return { + ...currentSnapshot, + ...nextSnapshot, + immutablePaths: mergeEntries(currentSnapshot.immutablePaths, nextSnapshot.immutablePaths), + managedPaths: mergeEntries(currentSnapshot.managedPaths, nextSnapshot.managedPaths), + unmanagedPaths: mergeEntries(currentSnapshot.unmanagedPaths, nextSnapshot.unmanagedPaths) + }; +}; + +/** + * @param {string} rootPath + * @param {Record} alias + * @returns {Record} + */ +const normalizeAlias = (rootPath, alias) => Object.fromEntries( + Object.entries(alias).map(([key, value]) => [key, maybeResolvePath(rootPath, value)]) +); + +/** + * @param {WebpackManifest} manifest + * @returns {Required} + */ +const normalizeManifest = manifest => { + const rootPath = path.resolve(manifest.rootPath); + const srcPath = path.resolve(rootPath, manifest.srcPath ?? 'src'); + const distPath = path.resolve(rootPath, manifest.distPath ?? 'dist'); + + return { + entry: manifest.entry, + libraryName: manifest.libraryName, + rootPath, + srcPath, + distPath, + publicPath: manifest.publicPath ?? '/', + sourcePaths: unique(toArray(manifest.sourcePaths).map(sourcePath => path.resolve(rootPath, sourcePath))), + enableReact: manifest.enableReact ?? false, + enableTs: manifest.enableTs ?? false, + shouldSplitChunks: manifest.shouldSplitChunks ?? false, + rules: (manifest.rules ?? []).map(rule => normalizeRule(rootPath, rule)), + alias: normalizeAlias(rootPath, manifest.alias ?? {}), + plugins: manifest.plugins ?? [], + snapshot: normalizeSnapshot(rootPath, manifest.snapshot) ?? {}, + target: manifest.target ?? 'web', + playground: manifest.playground ?? false, + workspacePackages: manifest.workspacePackages ?? [] + }; +}; + +/** + * @param {Required} manifest + * @returns {string} + */ +const getEntryPath = manifest => maybeResolvePath(manifest.rootPath, manifest.entry); + +/** + * @param {string} packageName + * @param {Required} manifest + * @returns {Record} + */ +const createWorkspaceAliases = (packageName, manifest) => ({ + [packageName]: manifest.srcPath, + [`${packageName}$`]: getEntryPath(manifest) +}); + +/** + * @param {Required} manifest + * @param {string[]} sourcePaths + * @param {string[]} cssSourcePaths + * @returns {RuleSetRule[]} + */ +const createDefaultRules = (manifest, sourcePaths, cssSourcePaths) => { + /** @type {RuleSetRule[]} */ + const rules = []; + + if (manifest.enableTs) { + rules.push({ + include: sourcePaths, + test: /\.([cm]?ts|tsx)$/, + loader: 'ts-loader', + options: DEFAULT_TS_LOADER_OPTIONS + }); + } + + rules.push({ + include: sourcePaths, + test: manifest.enableReact ? /\.[cm]?jsx?$/ : /\.[cm]?js$/, + loader: 'babel-loader', + options: { + babelrc: false, + presets: [ + '@babel/preset-env', + ...(manifest.enableReact ? ['@babel/preset-react'] : []) + ] + } + }); + + if (manifest.enableReact) { + rules.push({ + include: cssSourcePaths, + test: /\.css$/, + use: [{ + loader: 'style-loader' + }, { + loader: 'css-loader', + options: { + modules: { + localIdentName: '[name]_[local]_[hash:base64:5]', + exportLocalsConvention: 'camelCase' + }, + importLoaders: 1 + } + }, { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [ + 'postcss-import', + 'autoprefixer' + ] + } + } + }] + }); + } + + rules.push( + { + test: /\.hex$/, + type: 'asset/inline', + generator: { + dataUrl: createHexDataUrl + } + }, + { + resourceQuery: '?arrayBuffer', + type: 'javascript/auto', + use: 'arraybuffer-loader' + }, + { + resourceQuery: /raw/, + type: 'asset/source' + } + ); + + return rules; +}; + +/** + * @param {string} packageName + * @returns {WebpackManifest | null} + */ +const resolveWorkspacePackageManifest = packageName => { + try { + const packageJsonPath = require.resolve(`${packageName}/package.json`); + const packageDir = path.dirname(packageJsonPath); + const webpackManifestPath = path.join(packageDir, 'webpack.manifest.js'); + + if (!fs.existsSync(webpackManifestPath)) return null; + + const manifest = require(webpackManifestPath); + return manifest.default ?? manifest; + } catch { + return null; + } +}; + +class WebpackConfigBuilder { + /** + * @param {WebpackManifest} manifest + */ + constructor(manifest) { + /** @readonly @private @type {Set} */ + this.loadedWorkspacePackages = new Set(); + /** @type {Required} */ + this.manifest = normalizeManifest(manifest); + /** @readonly @private @type {string[]} */ + this.localSourcePaths = unique([ + this.manifest.srcPath, + ...this.manifest.sourcePaths + ]); + + for (const packageName of this.manifest.workspacePackages) { + this.addWorkspacePackage(packageName); + } + } + + /** + * @param {string} packageName + * @returns {this} + */ + addWorkspacePackage(packageName) { + if (this.loadedWorkspacePackages.has(packageName)) { + return this; + } + + this.loadedWorkspacePackages.add(packageName); + + const workspaceManifest = resolveWorkspacePackageManifest(packageName); + if (!workspaceManifest) { + console.warn(`Package ${packageName} does not have a webpack manifest, skipping.`); + return this; + } + + const normalizedManifest = normalizeManifest(workspaceManifest); + + for (const dependencyName of normalizedManifest.workspacePackages) { + this.addWorkspacePackage(dependencyName); + } + + this.manifest.enableReact ||= normalizedManifest.enableReact; + this.manifest.enableTs ||= normalizedManifest.enableTs; + this.manifest.shouldSplitChunks ||= normalizedManifest.shouldSplitChunks; + this.manifest.sourcePaths = unique([ + ...this.manifest.sourcePaths, + normalizedManifest.srcPath, + ...normalizedManifest.sourcePaths + ]); + + this.manifest.alias = { + ...createWorkspaceAliases(packageName, normalizedManifest), + ...normalizedManifest.alias, + ...this.manifest.alias + }; + + if (normalizedManifest.rules.length > 0) { + this.manifest.rules = [{ + include: normalizedManifest.srcPath, + rules: normalizedManifest.rules + }, ...this.manifest.rules]; + } + + this.manifest.snapshot = mergeSnapshot(this.manifest.snapshot, normalizedManifest.snapshot); + this.manifest.workspacePackages = unique([...this.manifest.workspacePackages, packageName]); + + return this; + } + + /** + * @returns {Configuration} + */ + get() { + const sourcePaths = unique([ + this.manifest.srcPath, + ...this.manifest.sourcePaths + ]); + + const targetingNode = this.manifest.target?.toString().startsWith('node'); + + /** @type {Configuration['output']} */ + const output = { + path: this.manifest.distPath, + publicPath: this.manifest.publicPath, + filename: '[name].js', + chunkFilename: DEFAULT_CHUNK_FILENAME, + ...(targetingNode ? { + library: { + type: 'commonjs2' + } + } : { + library: { + name: this.manifest.libraryName, + type: 'umd' + } + }) + }; + + /** @type {ConfigWithDevServer} */ + const configuration = { + context: this.manifest.rootPath, + mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', + devtool: 'cheap-module-source-map', + target: this.manifest.target, + entry: this.manifest.entry, + output, + resolve: { + extensions: this.manifest.enableReact ? ['.ts', '.js', '.tsx', '.jsx'] : (this.manifest.enableTs ? ['.ts', '.js'] : ['.js']), + alias: this.manifest.alias, + symlinks: false + }, + module: { + rules: [ + ...createDefaultRules(this.manifest, sourcePaths, this.localSourcePaths), + ...this.manifest.rules + ] + }, + plugins: [...this.manifest.plugins] + }; + + if (Object.keys(this.manifest.snapshot).length > 0) { + configuration.snapshot = this.manifest.snapshot; + } + + if (this.manifest.shouldSplitChunks) { + configuration.optimization = { + ...(configuration.optimization ?? {}), + splitChunks: { + chunks: 'async' + } + }; + } + + if (this.manifest.playground) { + configuration.devServer = { + static: this.manifest.distPath, + host: '0.0.0.0', + port: typeof this.manifest.playground === 'number' ? this.manifest.playground : (process.env.PORT || 'auto') + }; + } + + configuration.optimization = { + ...(configuration.optimization ?? {}), + minimize: process.env.NODE_ENV === 'production', + minimizer: [ + new TerserPlugin({ + include: /\.min\.js$/ + }) + ] + }; + + if (targetingNode) { + configuration.externalsPresets = {node: true}; + configuration.externals = [nodeExternals()]; + } + + return configuration; + } +} + +module.exports = WebpackConfigBuilder; +module.exports.default = WebpackConfigBuilder; diff --git a/packages/infra/tsconfig.dts.json b/packages/infra/tsconfig.dts.json new file mode 100644 index 00000000..440768f2 --- /dev/null +++ b/packages/infra/tsconfig.dts.json @@ -0,0 +1,27 @@ +// Reference: https://www.typescriptlang.org/docs/handbook/declaration-files/dts-from-js.html +{ + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules" + ], + "compilerOptions": { + // Tells TypeScript to read JS files, as + // normally they are ignored as source files + "allowJs": true, + // Generate d.ts files + "declaration": true, + // This compiler run should + // only output d.ts files + "emitDeclarationOnly": true, + // Types should go into this directory. + // Removing this would place the .d.ts files + // next to the .js files + "outDir": "./dist/types/", + // go to js file when using IDE functions like + // "Go to Definition" in VSCode + "declarationMap": true, + "skipLibCheck": true + } +} diff --git a/packages/vm/webpack.config.js b/packages/vm/webpack.config.js index 45440f5c..5074cde7 100644 --- a/packages/vm/webpack.config.js +++ b/packages/vm/webpack.config.js @@ -1,150 +1,81 @@ -const webpack = require('webpack'); const CopyWebpackPlugin = require('copy-webpack-plugin'); -const defaultsDeep = require('lodash.defaultsdeep'); -const path = require('path'); -const TerserPlugin = require('terser-webpack-plugin'); -const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); -const {version} = require('../../package.json'); +const WebpackConfigBuilder = require('../infra'); +const manifest = require('./webpack.manifest'); -const base = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', - output: { - library: 'VirtualMachine', - libraryTarget: 'umd', - filename: '[name].js' - }, - resolve: { - alias: { - 'text-encoding': 'fastestsmallesttextencoderdecoder', - 'clipcc-render': path.resolve(__dirname, '../render/src/index.js'), - 'clipcc-audio': path.resolve(__dirname, '../audio/src/index.js') - }, - extensions: ['.ts', '.js'] - }, - module: { - rules: [{ - include: [ - path.resolve('src'), - path.resolve('../render/src') - ], - test: /\.([cm]?ts|tsx)$/, - loader: 'ts-loader', - options: { - transpileOnly: true, - allowTsInNodeModules: true - } - }, - { - test: /\.js$/, - loader: 'babel-loader', - include: path.resolve(__dirname, 'src'), - options: { - presets: [['@babel/preset-env', {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}}]] - } - }, - { - test: /\.mp3$/, - type: 'asset/resource' - }, - { - resourceQuery: /raw/, - type: 'asset/source' - }, - { - resourceQuery: '?arrayBuffer', - type: 'javascript/auto', - use: 'arraybuffer-loader' - }] - }, - optimization: { - minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/ - }) - ] +const createConfig = (overrideManifest) => { + const config = new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest + }).get(); + + return config; +}; + +// Web-compatible +const web = createConfig({ + target: 'web', + distPath: './dist/web', + entry: { + 'scratch-vm': './src/index.js', + 'scratch-vm.min': './src/index.js' + } +}); + +// Node-compatible +const node = createConfig({ + target: 'node', + distPath: './dist/node', + entry: { + 'scratch-vm': './src/index.js' + } +}); +node.externals = { + 'decode-html': true, + 'format-message': true, + 'htmlparser2': true, + 'immutable': true, + 'jszip': true, + '@turbowarp/nanolog': true, + 'clipcc-parser': true, + 'socket.io-client': true +}; + +// Playground +const playground = createConfig({ + target: 'web', + distPath: './playground', + entry: { + 'benchmark': './src/playground/benchmark', + 'video-sensing-extension-debug': './src/extensions/scratch3_video_sensing/debug' }, - plugins: [ - new NodePolyfillPlugin(), - new webpack.DefinePlugin({ - 'clipcc.VERSION': version, - 'clipcc.BUILD_TIME': Date.now() - }) - ] + playground: true +}); +playground.devServer.static = false; +playground.devServer.port = process.env.PORT || 8073; +playground.module.rules.push({ + test: require.resolve('stats.js/build/stats.min.js'), + loader: 'script-loader' +}); +playground.performance = { + hints: false }; +playground.plugins = playground.plugins.concat([ + new CopyWebpackPlugin({ + patterns: [{ + from: '../block/media', + to: 'media' + }, { + from: '../storage/dist/web' + }, { + from: '../render/dist/web' + }, { + from: 'src/playground' + }] + }) +]); module.exports = [ - // Web-compatible - defaultsDeep({}, base, { - target: 'web', - entry: { - 'scratch-vm': './src/index.js', - 'scratch-vm.min': './src/index.js' - }, - output: { - path: path.resolve('dist', 'web') - } - }), - // Node-compatible - defaultsDeep({}, base, { - target: 'node', - entry: { - 'scratch-vm': './src/index.js' - }, - output: { - path: path.resolve('dist', 'node') - }, - externals: { - 'decode-html': true, - 'format-message': true, - 'htmlparser2': true, - 'immutable': true, - 'jszip': true, - '@turbowarp/nanolog': true, - 'clipcc-parser': true, - 'socket.io-client': true - } - }), - // Playground - defaultsDeep({}, base, { - target: 'web', - entry: { - 'benchmark': './src/playground/benchmark', - 'video-sensing-extension-debug': './src/extensions/scratch3_video_sensing/debug' - }, - devServer: { - static: false, - host: '0.0.0.0', - port: process.env.PORT || 8073 - }, - output: { - path: path.resolve(__dirname, 'playground'), - filename: '[name].js' - }, - module: { - rules: base.module.rules.concat([ - { - test: require.resolve('stats.js/build/stats.min.js'), - loader: 'script-loader' - } - ]) - }, - performance: { - hints: false - }, - plugins: base.plugins.concat([ - new CopyWebpackPlugin({ - patterns: [{ - from: '../block/media', - to: 'media' - }, { - from: '../storage/dist/web' - }, { - from: '../render/dist/web' - }, { - from: 'src/playground' - }] - }) - ]) - }) + web, + node, + playground ]; diff --git a/packages/vm/webpack.manifest.js b/packages/vm/webpack.manifest.js new file mode 100644 index 00000000..a0245cef --- /dev/null +++ b/packages/vm/webpack.manifest.js @@ -0,0 +1,34 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ +const webpack = require('webpack'); +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); +const {version} = require('../../package.json'); + +/** @type {WebpackManifest} */ +const manifest = { + libraryName: 'VirtualMachine', + rootPath: __dirname, + entry: './src/index.js', + enableTs: true, + sourcePaths: ['../render/src'], + alias: { + 'text-encoding': 'fastestsmallesttextencoderdecoder', + 'clipcc-render': '../render/src/index.js', // @todo should move to workspacePackages when it gets migrated. + 'clipcc-audio': '../audio/src/index.js' // @todo should move to workspacePackages when it gets migrated. + }, + rules: [{ + test: /\.mp3$/, + type: 'asset/resource' + }], + plugins: [ + new NodePolyfillPlugin(), + new webpack.DefinePlugin({ + 'clipcc.VERSION': version, + 'clipcc.BUILD_TIME': Date.now() + }) + ] +}; + +module.exports = manifest; From 3cc1d5a54b1cb5768dbc22ee9e0cc04c6ec833bd Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 11 Mar 2026 18:09:46 +0800 Subject: [PATCH 03/10] :wrench: chore: migrate more packages Signed-off-by: SimonShiki --- packages/audio/package.json | 2 +- packages/audio/webpack.config.js | 48 ++------ packages/audio/webpack.manifest.js | 13 +++ packages/block/webpack.config.js | 1 - packages/block/webpack.manifest.js | 1 + packages/infra/src/index.js | 79 ++++++++++++- packages/l10n/webpack.config.js | 31 ++--- packages/l10n/webpack.manifest.js | 13 +++ packages/paint/webpack.config.js | 139 ++++++----------------- packages/paint/webpack.manifest.js | 23 ++++ packages/render/webpack.config.js | 170 ++++++++++------------------ packages/render/webpack.manifest.js | 14 +++ 12 files changed, 255 insertions(+), 279 deletions(-) create mode 100644 packages/audio/webpack.manifest.js create mode 100644 packages/l10n/webpack.manifest.js create mode 100644 packages/paint/webpack.manifest.js create mode 100644 packages/render/webpack.manifest.js diff --git a/packages/audio/package.json b/packages/audio/package.json index a0e912ca..bbd40af9 100644 --- a/packages/audio/package.json +++ b/packages/audio/package.json @@ -2,7 +2,7 @@ "name": "clipcc-audio", "version": "3.2.0", "description": "audio engine for scratch 3.0", - "main": "dist.js", + "main": "dist/dist.js", "browser": "./src/index.js", "types": "./dist/types/index.d.ts", "scripts": { diff --git a/packages/audio/webpack.config.js b/packages/audio/webpack.config.js index 70f52fa0..fb9ebcb0 100644 --- a/packages/audio/webpack.config.js +++ b/packages/audio/webpack.config.js @@ -1,40 +1,16 @@ -const path = require('path'); +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); -module.exports = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', +const config = new WebpackConfigBuilder({ + ...manifest, entry: { - dist: './src/index.js' - }, - output: { - path: __dirname, - library: 'AudioEngine', - libraryTarget: 'umd', - filename: '[name].js' - }, - module: { - rules: [{ - include: [ - path.resolve('src') - ], - test: /\.([cm]?ts|tsx)$/, - loader: 'ts-loader', - options: { - transpileOnly: true, - allowTsInNodeModules: true - } - }, { - test: /\.js$/, - include: path.resolve(__dirname, 'src'), - loader: 'babel-loader', - options: { - presets: [['@babel/preset-env', {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}}]] - } - }] - }, - externals: { - 'audio-context': true, - '@turbowarp/nanolog': true, - 'startaudiocontext': true + dist: manifest.entry } +}).get(); +config.externals = { + 'audio-context': true, + '@turbowarp/nanolog': true, + 'startaudiocontext': true }; + +module.exports = config; diff --git a/packages/audio/webpack.manifest.js b/packages/audio/webpack.manifest.js new file mode 100644 index 00000000..4f6b485a --- /dev/null +++ b/packages/audio/webpack.manifest.js @@ -0,0 +1,13 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const manifest = { + entry: './src/index.js', + libraryName: 'AudioEngine', + rootPath: __dirname +}; + +module.exports = manifest; diff --git a/packages/block/webpack.config.js b/packages/block/webpack.config.js index 11b884ee..371015f6 100644 --- a/packages/block/webpack.config.js +++ b/packages/block/webpack.config.js @@ -10,7 +10,6 @@ const createConfig = (overrideManifest) => { ...overrideManifest }).get(); - config.devtool = process.env.NODE_ENV === 'production' ? false : 'eval-cheap-module-source-map'; config.ignoreWarnings = [/Failed to parse source map/]; return config; diff --git a/packages/block/webpack.manifest.js b/packages/block/webpack.manifest.js index 5649be47..b67eee79 100644 --- a/packages/block/webpack.manifest.js +++ b/packages/block/webpack.manifest.js @@ -7,6 +7,7 @@ const manifest = { libraryName: 'ScratchBlocks', rootPath: __dirname, + devTool: process.env.NODE_ENV === 'production' ? false : 'eval-cheap-module-source-map', entry: './src/index.ts', enableTs: true, rules: [{ diff --git a/packages/infra/src/index.js b/packages/infra/src/index.js index 0a4ee030..48b0c1d0 100644 --- a/packages/infra/src/index.js +++ b/packages/infra/src/index.js @@ -9,6 +9,7 @@ const nodeExternals = require('webpack-node-externals'); /** @typedef {import('webpack').Configuration} Configuration */ /** @typedef {import('webpack').RuleSetRule} RuleSetRule */ /** @typedef {NonNullable} SnapshotConfig */ +/** @typedef {NonNullable} EntryConfig */ /** * @typedef {Configuration & { * devServer?: { @@ -28,9 +29,10 @@ const nodeExternals = require('webpack-node-externals'); */ /** * @typedef {{ - * entry: string, + * entry: EntryConfig, * libraryName: string, * target?: Configuration['target'], + * devTool?: Configuration['devtool'], * rootPath: string, * srcPath?: string, * distPath?: string, @@ -110,6 +112,76 @@ const maybeResolvePath = (rootPath, value) => { return fs.existsSync(resolvedPath) ? resolvedPath : value; }; +/** + * @param {string} rootPath + * @param {EntryConfig} entry + * @returns {EntryConfig} + */ +const normalizeEntry = (rootPath, entry) => { + if (typeof entry === 'string') { + return maybeResolvePath(rootPath, entry); + } + + if (Array.isArray(entry)) { + return entry.map(value => maybeResolvePath(rootPath, value)); + } + + return Object.fromEntries( + Object.entries(entry).map(([key, value]) => { + if (typeof value === 'string' || Array.isArray(value)) { + return [key, normalizeEntry(rootPath, value)]; + } + + if (value && typeof value === 'object' && 'import' in value) { + return [key, { + ...value, + ...(value.import ? {import: normalizeEntry(rootPath, value.import)} : {}) + }]; + } + + return [key, value]; + }) + ); +}; + +/** + * @param {EntryConfig} entry + * @returns {string | undefined} + */ +const findFirstEntryPath = entry => { + if (typeof entry === 'string') { + return entry; + } + + if (Array.isArray(entry)) { + return entry[0]; + } + + for (const value of Object.values(entry)) { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value[0]; + } + + if (value && typeof value === 'object' && 'import' in value) { + const entryPath = value.import; + + if (typeof entryPath === 'string') { + return entryPath; + } + + if (Array.isArray(entryPath)) { + return entryPath[0]; + } + } + } + + return undefined; +}; + /** * @param {string} rootPath * @param {RuleSetRule['include'] | RuleSetRule['exclude']=} condition @@ -217,8 +289,9 @@ const normalizeManifest = manifest => { const distPath = path.resolve(rootPath, manifest.distPath ?? 'dist'); return { - entry: manifest.entry, + entry: normalizeEntry(rootPath, manifest.entry), libraryName: manifest.libraryName, + devTool: manifest.devTool ?? 'cheap-module-source-map', rootPath, srcPath, distPath, @@ -241,7 +314,7 @@ const normalizeManifest = manifest => { * @param {Required} manifest * @returns {string} */ -const getEntryPath = manifest => maybeResolvePath(manifest.rootPath, manifest.entry); +const getEntryPath = manifest => findFirstEntryPath(manifest.entry) ?? manifest.srcPath; /** * @param {string} packageName diff --git a/packages/l10n/webpack.config.js b/packages/l10n/webpack.config.js index 675e8380..554ca054 100644 --- a/packages/l10n/webpack.config.js +++ b/packages/l10n/webpack.config.js @@ -1,28 +1,13 @@ -const path = require('path'); +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); -module.exports = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', - module: { - rules: [{ - test: /\.js$/, - include: path.resolve(__dirname, 'src'), - use: { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'] - } - } - }] - }, +const config = new WebpackConfigBuilder(Object.assign(manifest, { entry: { - l10n: './src/index.js', + l10n: manifest.entry, supportedLocales: './src/supported-locales.js', localeData: './src/locale-data.js' - }, - output: { - path: path.resolve(__dirname, 'dist'), - filename: '[name].js', - libraryTarget: 'commonjs2' } -}; +})).get(); +config.devtool = 'cheap-module-source-map'; + +module.exports = config; diff --git a/packages/l10n/webpack.manifest.js b/packages/l10n/webpack.manifest.js new file mode 100644 index 00000000..a54b43a7 --- /dev/null +++ b/packages/l10n/webpack.manifest.js @@ -0,0 +1,13 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const manifest = { + entry: './src/index.js', + libraryName: 'l10n', + rootPath: __dirname +}; + +module.exports = manifest; diff --git a/packages/paint/webpack.config.js b/packages/paint/webpack.config.js index 6c8b32eb..b7fb6caa 100644 --- a/packages/paint/webpack.config.js +++ b/packages/paint/webpack.config.js @@ -1,111 +1,46 @@ -const defaultsDeep = require('lodash.defaultsdeep'); const path = require('path'); +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); // Plugins const HtmlWebpackPlugin = require('html-webpack-plugin'); -const TerserPlugin = require('terser-webpack-plugin'); -const base = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', - module: { - rules: [{ - test: /\.jsx?$/, - loader: 'babel-loader', - include: path.resolve(__dirname, 'src'), - options: { - presets: ['@babel/preset-env', '@babel/preset-react'] - } - }, - { - test: /\.css$/, - use: [{ - loader: 'style-loader' - }, { - loader: 'css-loader', - options: { - modules: { - localIdentName: '[name]_[local]_[fullhash:base64:5]', - exportLocalsConvention: 'camelCase' - }, - importLoaders: 1 - } - }, { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - 'postcss-import', - 'autoprefixer' - ] - } - } - }] - }, - { - test: /\.png$/i, - type: 'asset/inline' - }, - { - test: /\.svg$/, - loader: 'svg-url-loader' - }] + +const playground = new WebpackConfigBuilder({ + ...manifest, + entry: { + playground: './src/playground/playground.jsx' }, - optimization: { - minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/ - }) - ] + playground: 8078, + distPath: path.resolve(__dirname, 'playground'), + plugins: [ + new HtmlWebpackPlugin({ + template: 'src/playground/index.ejs', + title: 'Scratch 3.0 Paint Editor Playground' + }) + ] +}).get(); + +const library = new WebpackConfigBuilder({ + ...manifest, + entry: { + 'scratch-paint': manifest.entry }, - plugins: [] + distPath: path.resolve(__dirname, 'dist') +}).get(); +library.externals = { + '@turbowarp/nanolog': '@turbowarp/nanolog', + 'prop-types': 'prop-types', + 'react': 'react', + 'react-dom': 'react-dom', + 'react-intl': 'react-intl', + 'react-intl-redux': 'react-intl-redux', + 'react-popover': 'react-popover', + 'react-redux': 'react-redux', + 'react-responsive': 'react-responsive', + 'react-style-proptype': 'react-style-proptype', + 'react-tooltip': 'react-tooltip', + 'redux': 'redux' }; -module.exports = [ - // For the playground - defaultsDeep({}, base, { - devServer: { - static: path.resolve(__dirname, 'playground'), - host: '0.0.0.0', - port: process.env.PORT || 8078 - }, - entry: { - playground: './src/playground/playground.jsx' - }, - output: { - path: path.resolve(__dirname, 'playground'), - filename: '[name].js' - }, - plugins: base.plugins.concat([ - new HtmlWebpackPlugin({ - template: 'src/playground/index.ejs', - title: 'Scratch 3.0 Paint Editor Playground' - }) - ]) - }), - // For use as a library - defaultsDeep({}, base, { - externals: { - '@turbowarp/nanolog': '@turbowarp/nanolog', - 'prop-types': 'prop-types', - 'react': 'react', - 'react-dom': 'react-dom', - 'react-intl': 'react-intl', - 'react-intl-redux': 'react-intl-redux', - 'react-popover': 'react-popover', - 'react-redux': 'react-redux', - 'react-responsive': 'react-responsive', - 'react-style-proptype': 'react-style-proptype', - 'react-tooltip': 'react-tooltip', - 'redux': 'redux' - }, - entry: { - 'scratch-paint': './src/index.js' - }, - output: { - path: path.resolve(__dirname, 'dist'), - filename: '[name].js', - libraryTarget: 'commonjs2' - } - }) -]; +module.exports = [playground, library]; diff --git a/packages/paint/webpack.manifest.js b/packages/paint/webpack.manifest.js new file mode 100644 index 00000000..9b461e68 --- /dev/null +++ b/packages/paint/webpack.manifest.js @@ -0,0 +1,23 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const manifest = { + entry: './src/index.js', + libraryName: 'ScratchPaint', + rootPath: __dirname, + enableReact: true, + enableTs: true, + rules: [{ + test: /\.png$/i, + type: 'asset/inline' + }, + { + test: /\.svg$/, + loader: 'svg-url-loader' + }] +}; + +module.exports = manifest; diff --git a/packages/render/webpack.config.js b/packages/render/webpack.config.js index 762d2cb5..15a8abb1 100644 --- a/packages/render/webpack.config.js +++ b/packages/render/webpack.config.js @@ -1,118 +1,62 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); -const path = require('path'); -const TerserPlugin = require('terser-webpack-plugin'); -const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); -const base = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - resolve: { - extensions: ['.ts', '.js'] - }, - devtool: 'cheap-module-source-map', - module: { - rules: [ - { - include: [ - path.resolve('src') - ], - test: /\.([cm]?ts|tsx)$/, - loader: 'ts-loader', - options: { - transpileOnly: true, - allowTsInNodeModules: true - } - }, - { - include: [ - path.resolve('src') - ], - test: /\.js$/, - loader: 'babel-loader', - options: { - presets: [[ - '@babel/preset-env', - {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}} - ]] - } - }, - { - resourceQuery: /raw/, - type: 'asset/source' - } - ] - }, - optimization: { - minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/ - }) - ] - }, - plugins: [ - new NodePolyfillPlugin() - ] +const createConfig = overrideManifest => { + const config = new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest + }).get(); + config.devtool = 'cheap-module-source-map'; + + return config; }; -module.exports = [ - // Playground - Object.assign({}, base, { - target: 'web', - devServer: { - static: false, - host: '0.0.0.0', - port: process.env.PORT || 8361 - }, - entry: { - playground: './src/playground/playground.js', - queryPlayground: './src/playground/queryPlayground.js' - }, - output: { - libraryTarget: 'umd', - path: path.resolve('playground'), - filename: '[name].js' - }, - plugins: base.plugins.concat([ - new CopyWebpackPlugin({ - patterns: [{ - context: 'src/playground', - from: '*.+(html|css)' - }] - }) - ]) - }), - // Web-compatible - Object.assign({}, base, { - target: 'web', - entry: { - 'scratch-render': './src/index.js', - 'scratch-render.min': './src/index.js' - }, - output: { - library: 'ScratchRender', - libraryTarget: 'umd', - path: path.resolve('dist', 'web'), - filename: '[name].js' - } - }), - // Node-compatible - Object.assign({}, base, { - target: 'node', - entry: { - 'scratch-render': './src/index.js' - }, - output: { - library: 'ScratchRender', - libraryTarget: 'commonjs2', - path: path.resolve('dist', 'node'), - filename: '[name].js' - }, - externals: { - '!ify-loader!grapheme-breaker': 'grapheme-breaker', - '!ify-loader!linebreak': 'linebreak', - 'hull.js': true, - 'clipcc-svg-renderer': true, - 'twgl.js': true, - 'xml-escape': true - } +// Playground +const playground = createConfig({ + target: 'web', + distPath: './playground', + entry: { + playground: './src/playground/playground.js', + queryPlayground: './src/playground/queryPlayground.js' + }, + playground: 8361 +}); + +playground.plugins.push( + new CopyWebpackPlugin({ + patterns: [{ + context: 'src/playground', + from: '*.+(html|css)' + }] }) -]; +); + +// Web-compatible +const web = createConfig({ + target: 'web', + distPath: './dist/web', + entry: { + 'scratch-render': './src/index.js', + 'scratch-render.min': './src/index.js' + } +}); + +// Node-compatible +const node = createConfig({ + target: 'node', + distPath: './dist/node', + entry: { + 'scratch-render': './src/index.js' + }, + externals: { + '!ify-loader!grapheme-breaker': 'grapheme-breaker', + '!ify-loader!linebreak': 'linebreak', + 'hull.js': true, + 'twgl.js': true, + 'xml-escape': true, + 'clipcc-svg-renderer': true + } +}); + +module.exports = [playground, web, node]; diff --git a/packages/render/webpack.manifest.js b/packages/render/webpack.manifest.js new file mode 100644 index 00000000..88751e2a --- /dev/null +++ b/packages/render/webpack.manifest.js @@ -0,0 +1,14 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @type {WebpackManifest} */ +const manifest = { + libraryName: 'ClipCCRender', + entry: './src/index.js', + rootPath: __dirname, + enableTs: true +}; + +module.exports = manifest; From 644022c57bb3bd698198ca458d57cfed909979e5 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 11 Mar 2026 22:58:12 +0800 Subject: [PATCH 04/10] :wrench: chore: make build work Signed-off-by: SimonShiki --- packages/audio/webpack.config.js | 10 +- packages/audio/webpack.manifest.js | 2 +- packages/block/webpack.config.js | 13 +- packages/block/webpack.manifest.js | 2 +- packages/gui/scripts/block-message-loader.js | 2 + packages/gui/webpack.config.js | 449 +++++++------------ packages/gui/webpack.manifest.js | 56 ++- packages/infra/package.json | 1 - packages/infra/src/index.js | 44 +- packages/l10n/webpack.config.js | 1 - packages/l10n/webpack.manifest.js | 2 +- packages/paint/webpack.config.js | 30 +- packages/paint/webpack.manifest.js | 2 +- packages/render/webpack.config.js | 4 +- packages/render/webpack.manifest.js | 2 +- packages/storage/webpack.config.js | 126 ++---- packages/storage/webpack.manifest.js | 22 + packages/vm/package.json | 6 +- packages/vm/webpack.config.js | 20 +- packages/vm/webpack.manifest.js | 7 +- 20 files changed, 343 insertions(+), 458 deletions(-) create mode 100644 packages/storage/webpack.manifest.js diff --git a/packages/audio/webpack.config.js b/packages/audio/webpack.config.js index fb9ebcb0..bfdec426 100644 --- a/packages/audio/webpack.config.js +++ b/packages/audio/webpack.config.js @@ -5,12 +5,12 @@ const config = new WebpackConfigBuilder({ ...manifest, entry: { dist: manifest.entry + }, + externals: { + 'audio-context': true, + '@turbowarp/nanolog': true, + 'startaudiocontext': true } }).get(); -config.externals = { - 'audio-context': true, - '@turbowarp/nanolog': true, - 'startaudiocontext': true -}; module.exports = config; diff --git a/packages/audio/webpack.manifest.js b/packages/audio/webpack.manifest.js index 4f6b485a..b3a7272e 100644 --- a/packages/audio/webpack.manifest.js +++ b/packages/audio/webpack.manifest.js @@ -3,7 +3,7 @@ * @import { WebpackManifest } from '../infra'; */ -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { entry: './src/index.js', libraryName: 'AudioEngine', diff --git a/packages/block/webpack.config.js b/packages/block/webpack.config.js index 371015f6..f20859f3 100644 --- a/packages/block/webpack.config.js +++ b/packages/block/webpack.config.js @@ -46,15 +46,14 @@ playground.devServer.static = false; // Node-compatible const node = createConfig({ distPath: './dist/node', - target: 'node' + target: 'node', + externals: { + bufferutil: true, + 'utf-8-validate': true, + canvas: true + } }); -node.externals = { - bufferutil: true, - 'utf-8-validate': true, - canvas: true -}; - // Web-comptible const web = createConfig({ distPath: './dist/web', diff --git a/packages/block/webpack.manifest.js b/packages/block/webpack.manifest.js index b67eee79..d7d93800 100644 --- a/packages/block/webpack.manifest.js +++ b/packages/block/webpack.manifest.js @@ -3,7 +3,7 @@ * @import { WebpackManifest } from '../infra'; */ -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { libraryName: 'ScratchBlocks', rootPath: __dirname, diff --git a/packages/gui/scripts/block-message-loader.js b/packages/gui/scripts/block-message-loader.js index 7c456710..1b629926 100644 --- a/packages/gui/scripts/block-message-loader.js +++ b/packages/gui/scripts/block-message-loader.js @@ -8,6 +8,8 @@ const path = require('node:path'); module.exports = function (/** @type {string} */ source) { if (!source.includes('export default')) return source; + console.log(123); + const messagePath = path.resolve(__dirname, '../../block/msg/messages.js'); const content = fs.readFileSync(messagePath, {encoding: 'utf-8'}); this.addDependency(messagePath); diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index 5bb52a69..3848bb1f 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -1,344 +1,195 @@ -const defaultsDeep = require('lodash.defaultsdeep'); const path = require('path'); const webpack = require('webpack'); const {version} = require('../../package.json'); +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); + // Plugins const CopyWebpackPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const TerserPlugin = require('terser-webpack-plugin'); -const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin'); const STATIC_PATH = process.env.STATIC_PATH || '/static'; -const base = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', - devServer: { - static: path.resolve(__dirname, 'build'), - host: '0.0.0.0', - port: process.env.PORT || 8601 - }, - output: { - library: 'GUI', - filename: '[name].js', - chunkFilename: 'chunks/[name].js' - }, - resolve: { - extensions: ['.ts', '.js', '.tsx', '.jsx'], - alias: { - 'text-encoding': 'fastestsmallesttextencoderdecoder', - 'clipcc-vm': path.resolve(__dirname, '../vm/src/index.js'), - 'clipcc-block': path.resolve(__dirname, '../block/src/index.ts'), - 'clipcc-render': path.resolve(__dirname, '../render/src/index.js'), - 'clipcc-audio': path.resolve(__dirname, '../audio/src/index.js') - }, - symlinks: false - }, - snapshot: { - managedPaths: [ - /^.+?[\\/]node_modules[\\/](?!scratch-(blocks|l10n|paint|render|storage|vm))[\\/]/ - ] - }, - module: { - rules: [{ - include: [ - path.resolve(__dirname, 'src'), - path.resolve(__dirname, '../vm/src'), - path.resolve(__dirname, '../block/src'), - path.resolve(__dirname, '../audio/src') - ], - test: /\.([cm]?ts|tsx)$/, - loader: 'ts-loader', - options: { - transpileOnly: true, - allowTsInNodeModules: true - } - }, - { - test: /\.jsx?$/, - loader: 'babel-loader', - include: [ - path.resolve(__dirname, 'src'), - /node_modules[\\/]scratch-[^\\/]+[\\/]src/, - /node_modules[\\/]clipcc-[^\\/]+[\\/]src/, - /node_modules[\\/]pify/, - /node_modules[\\/]@vernier[\\/]godirect/ - ], - options: { - // Explicitly disable babelrc so we don't catch various config - // in much lower dependencies. - babelrc: false, - plugins: [ - ['react-intl', { - messagesDir: './translations/messages/' - }]], - presets: ['@babel/preset-env', '@babel/preset-react'] - } - }, - { - test: /\.css$/, - exclude: path.resolve(__dirname, '../block/src'), - use: [{ - loader: 'style-loader' - }, { - loader: 'css-loader', - options: { - modules: { - localIdentName: '[name]_[local]_[hash:base64:5]', - exportLocalsConvention: 'camelCase' - }, - importLoaders: 1 - } - }, { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - 'postcss-import', - 'autoprefixer' - ] - } - } - }] - }, { - test: /\.css$/, - include: path.resolve(__dirname, '../block/src'), - type: 'asset/source' - }, { - test: /\.hex$/, - type: 'asset/inline', - generator: { - dataUrl: content => `data:text/plain;base64,${content.toString('base64')}` - } - }, { - resourceQuery: '?arrayBuffer', - type: 'javascript/auto', - use: 'arraybuffer-loader' - }, { - resourceQuery: /raw/, - type: 'asset/source' - }] +const createConfig = overrideManifest => { + const config = new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest + }).get(); + + return config; +}; + +if (process.env.ANALYZE) { + // eslint-disable-next-line global-require + const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); + manifest.plugins.push(new BundleAnalyzerPlugin()); +} + +/** @type {webpack.Configuration} */ +const configs = []; + +const fallbackAssetRule = { + test: /\.(svg|png|wav|gif|jpg)$/, + resourceQuery: { not: [/raw/] }, + type: 'asset/inline' +}; + +// to run editor examples +const playground = createConfig({ + entry: { + gui: './src/playground/index.jsx', + blocksonly: './src/playground/blocks-only.jsx', + lifecycle: './src/playground/lifecycle-test.jsx', + compatibilitytesting: './src/playground/compatibility-testing.jsx', + player: './src/playground/player.jsx' }, + distPath: path.resolve(__dirname, 'build'), + rules: [...manifest.rules, fallbackAssetRule], optimization: { - minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/ - }), - new ImageMinimizerPlugin({ - minimizer: { - implementation: ImageMinimizerPlugin.imageminMinify, - options: { - plugins: [ - ['gifsicle', {interlaced: true}], - ['jpegtran', {progressive: true}], - ['optipng', {optimizationLevel: 5}] - ] - } + splitChunks: { + chunks: 'async', + minChunks: 2, + maxInitialRequests: 5, + cacheGroups: { + default: false, + defaultVendors: false, + lib: { + test: /[\\/]node_modules[\\/]/, + name: 'lib.min', + chunks: 'initial', + priority: 10, + reuseExistingChunk: true, + enforce: true } - }) - ] + } + }, + runtimeChunk: { + name: 'lib.min' + } }, plugins: [ - new NodePolyfillPlugin(), + ...manifest.plugins, + new webpack.DefinePlugin({ + 'process.env.DEBUG': Boolean(process.env.DEBUG), + 'process.env.GA_ID': `"${process.env.GA_ID || 'UA-000000-01'}"`, + 'clipcc.VERSION': version, + 'clipcc.BUILD_TIME': Date.now() + }), + new HtmlWebpackPlugin({ + chunks: ['lib.min', 'gui'], + template: 'src/playground/index.ejs', + title: 'ClipCC GUI' + }), + new HtmlWebpackPlugin({ + chunks: ['lib.min', 'blocksonly'], + template: 'src/playground/index.ejs', + filename: 'blocks-only.html', + title: 'ClipCC GUI: Blocks Only Example' + }), + new HtmlWebpackPlugin({ + chunks: ['lib.min', 'compatibilitytesting'], + template: 'src/playground/index.ejs', + filename: 'compatibility-testing.html', + title: 'ClipCC GUI: Compatibility Testing' + }), + new HtmlWebpackPlugin({ + chunks: ['lib.min', 'player'], + template: 'src/playground/index.ejs', + filename: 'player.html', + title: 'ClipCC GUI: Player Example' + }), + new HtmlWebpackPlugin({ + chunks: ['lib.min', 'lifecycle'], + template: 'src/playground/index.ejs', + filename: 'lifecycle.html', + title: 'ClipCC GUI: Lifecycle Test' + }), new CopyWebpackPlugin({ patterns: [ { - from: '../block/media', - to: 'static/blocks-media/default' - }, + from: 'static', + to: 'static' + } + ] + }), + new CopyWebpackPlugin({ + patterns: [ { - from: '../block/media', - to: 'static/blocks-media/high-contrast' - }, + from: 'extensions/**', + to: 'static', + context: 'src/examples' + } + ] + }), + new CopyWebpackPlugin({ + patterns: [ { - from: 'src/lib/themes/high-contrast/blocks-media', - to: 'static/blocks-media/high-contrast', - force: true + from: 'extension-worker.{js,js.map}', + context: '../vm/dist/web' } ] }) ] -}; - -if (!process.env.CI) { - base.plugins.push(new webpack.ProgressPlugin()); -} - -if (process.env.ANALYZE) { - // eslint-disable-next-line global-require - const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); - base.plugins.push(new BundleAnalyzerPlugin()); -} +}); -if (base.mode === 'development') { - base.module.rules.push({ - test: /blocks-msgs\.js$/, - include: [ - /node_modules[\\/]clipcc-l10n[\\/]locales/ - ], - use: [{ - loader: path.resolve(__dirname, 'scripts/block-message-loader.js') - }, { - loader: 'babel-loader' - }], - enforce: 'pre' - }); -} +configs.push(playground); -module.exports = [ - // to run editor examples - defaultsDeep({}, base, { +if (process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist') { + const lib = createConfig({ + target: 'web', + publicPath: STATIC_PATH, entry: { - gui: './src/playground/index.jsx', - blocksonly: './src/playground/blocks-only.jsx', - lifecycle: './src/playground/lifecycle-test.jsx', - compatibilitytesting: './src/playground/compatibility-testing.jsx', - player: './src/playground/player.jsx' + 'scratch-gui': './src/index.js' }, - output: { - path: path.resolve(__dirname, 'build'), - filename: '[name].js' + externals: { + 'react': 'react', + 'react-dom': 'react-dom' }, - module: { - rules: base.module.rules.concat([ - { - test: /\.(svg|png|wav|gif|jpg)$/, - resourceQuery: {not: [/raw/]}, - type: 'asset/inline' - } - ]) - }, - optimization: { - splitChunks: { - chunks: 'async', - minChunks: 2, - maxInitialRequests: 5, - cacheGroups: { - default: false, - defaultVendors: false, - lib: { - test: /[\\/]node_modules[\\/]/, - name: 'lib.min', - chunks: 'initial', - priority: 10, - reuseExistingChunk: true, - enforce: true - } - } - }, - runtimeChunk: { - name: 'lib.min' - } - }, - plugins: base.plugins.concat([ - new webpack.DefinePlugin({ - 'process.env.DEBUG': Boolean(process.env.DEBUG), - 'process.env.GA_ID': `"${process.env.GA_ID || 'UA-000000-01'}"`, - 'clipcc.VERSION': version, - 'clipcc.BUILD_TIME': Date.now() - }), - new HtmlWebpackPlugin({ - chunks: ['lib.min', 'gui'], - template: 'src/playground/index.ejs', - title: 'ClipCC GUI' - }), - new HtmlWebpackPlugin({ - chunks: ['lib.min', 'blocksonly'], - template: 'src/playground/index.ejs', - filename: 'blocks-only.html', - title: 'ClipCC GUI: Blocks Only Example' - }), - new HtmlWebpackPlugin({ - chunks: ['lib.min', 'compatibilitytesting'], - template: 'src/playground/index.ejs', - filename: 'compatibility-testing.html', - title: 'ClipCC GUI: Compatibility Testing' - }), - new HtmlWebpackPlugin({ - chunks: ['lib.min', 'player'], - template: 'src/playground/index.ejs', - filename: 'player.html', - title: 'ClipCC GUI: Player Example' - }), - new HtmlWebpackPlugin({ - chunks: ['lib.min', 'lifecycle'], - template: 'src/playground/index.ejs', - filename: 'lifecycle.html', - title: 'ClipCC GUI: Lifecycle Test' - }), + rules: [...manifest.rules, fallbackAssetRule], + plugins: [ new CopyWebpackPlugin({ patterns: [ { - from: 'static', - to: 'static' - } - ] - }), - new CopyWebpackPlugin({ - patterns: [ - { - from: 'extensions/**', - to: 'static', - context: 'src/examples' + from: 'extension-worker.{js,js.map}', + context: '../vm/dist/web' } ] }), + // Include library JSON files for scratch-desktop to use for downloading new CopyWebpackPlugin({ patterns: [ { - from: 'extension-worker.{js,js.map}', - context: '../vm/dist/web' + from: 'src/lib/libraries/*.json', + to: 'libraries/[name][ext]' } ] }) - ]) - }) -].concat( - process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist' ? ( - // export as library - defaultsDeep({}, base, { - target: 'web', - entry: { - 'scratch-gui': './src/index.js' - }, - output: { - libraryTarget: 'umd', - path: path.resolve('dist'), - publicPath: `${STATIC_PATH}/` - }, - externals: { - 'react': 'react', - 'react-dom': 'react-dom' - }, - module: { - rules: base.module.rules.concat([ - { - test: /\.(svg|png|wav|gif|jpg)$/, - resourceQuery: {not: [/raw/]}, - type: 'asset/inline' - } - ]) - }, - plugins: base.plugins.concat([ - new CopyWebpackPlugin({ - patterns: [ - { - from: 'extension-worker.{js,js.map}', - context: '../vm/dist/web' - } - ] + ], + optimization: { + minimizer: [ + new TerserPlugin({ + include: /\.min\.js$/ }), - // Include library JSON files for scratch-desktop to use for downloading - new CopyWebpackPlugin({ - patterns: [ - { - from: 'src/lib/libraries/*.json', - to: 'libraries/[name][ext]' + new ImageMinimizerPlugin({ + minimizer: { + implementation: ImageMinimizerPlugin.imageminMinify, + options: { + plugins: [ + ['gifsicle', { interlaced: true }], + ['jpegtran', { progressive: true }], + ['optipng', { optimizationLevel: 5 }] + ] } - ] + } }) - ]) - })) : [] -); + ] + } + }); + configs.push(lib); +} + +console.dir(playground, {depth: 20}); + +module.exports = configs; diff --git a/packages/gui/webpack.manifest.js b/packages/gui/webpack.manifest.js index 652b4630..9cc187a6 100644 --- a/packages/gui/webpack.manifest.js +++ b/packages/gui/webpack.manifest.js @@ -2,8 +2,11 @@ /** * @import { WebpackManifest } from '../infra'; */ +const webpack = require('webpack'); +const path = require('path'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const base = { libraryName: 'GUI', playground: 8601, @@ -13,12 +16,61 @@ const base = { enableReact: true, enableTs: true, + sourcePaths: [ + '../../node_modules/react-tabs' // for react-tabs' CSS + ], workspacePackages: [ 'clipcc-vm', 'clipcc-block', 'clipcc-paint', 'clipcc-render' - ] + ], + snapshot: { + managedPaths: [ + /^.+?[\\/]node_modules[\\/](?!scratch-(blocks|l10n|paint|render|storage|vm))[\\/]/ + ] + }, + /** @type {NonNullable} */ + rules: [], + /** @type {NonNullable} */ + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + { + from: '../block/media', + to: 'static/blocks-media/default' + }, + { + from: '../block/media', + to: 'static/blocks-media/high-contrast' + }, + { + from: 'src/lib/themes/high-contrast/blocks-media', + to: 'static/blocks-media/high-contrast', + force: true + } + ] + }) + ], }; +if (process.env.NODE_ENV === 'development') { + base.rules.push({ + test: /blocks-msgs\.js$/, + include: [ + /node_modules[\\/]clipcc-l10n[\\/]locales/ + ], + use: [{ + loader: path.resolve(__dirname, 'scripts/block-message-loader.js') + }, { + loader: 'babel-loader' + }], + enforce: 'pre' + }); +} + +if (!process.env.CI) { + base.plugins.push(new webpack.ProgressPlugin()); +} + module.exports = base; diff --git a/packages/infra/package.json b/packages/infra/package.json index 3255f64b..5b25bfc1 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -25,7 +25,6 @@ }, "homepage": "https://github.com/Clipteamclipcc#readme", "dependencies": { - "lodash.merge": "^4.6.2", "webpack-node-externals": "^3.0.0" }, "devDependencies": { diff --git a/packages/infra/src/index.js b/packages/infra/src/index.js index 48b0c1d0..36553173 100644 --- a/packages/infra/src/index.js +++ b/packages/infra/src/index.js @@ -2,6 +2,7 @@ const path = require('path'); const fs = require('fs'); +const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); const nodeExternals = require('webpack-node-externals'); @@ -46,6 +47,7 @@ const nodeExternals = require('webpack-node-externals'); * alias?: Record, * snapshot?: SnapshotConfig, * playground?: boolean | number, + * externals?: Configuration['externals'], * workspacePackages?: string[] * }} WebpackManifest */ @@ -306,7 +308,8 @@ const normalizeManifest = manifest => { snapshot: normalizeSnapshot(rootPath, manifest.snapshot) ?? {}, target: manifest.target ?? 'web', playground: manifest.playground ?? false, - workspacePackages: manifest.workspacePackages ?? [] + workspacePackages: manifest.workspacePackages ?? [], + externals: manifest.externals ?? {} }; }; @@ -326,6 +329,15 @@ const createWorkspaceAliases = (packageName, manifest) => ({ [`${packageName}$`]: getEntryPath(manifest) }); +/** + * @param {Required} manifest + * @returns {string[]} + */ +const getManifestSourcePaths = manifest => unique([ + manifest.srcPath, + ...manifest.sourcePaths +]); + /** * @param {Required} manifest * @param {string[]} sourcePaths @@ -438,10 +450,9 @@ class WebpackConfigBuilder { /** @type {Required} */ this.manifest = normalizeManifest(manifest); /** @readonly @private @type {string[]} */ - this.localSourcePaths = unique([ - this.manifest.srcPath, - ...this.manifest.sourcePaths - ]); + this.localSourcePaths = getManifestSourcePaths(this.manifest); + /** @private @type {string[]} */ + this.reactSourcePaths = this.manifest.enableReact ? [...this.localSourcePaths] : []; for (const packageName of this.manifest.workspacePackages) { this.addWorkspacePackage(packageName); @@ -480,6 +491,13 @@ class WebpackConfigBuilder { ...normalizedManifest.sourcePaths ]); + if (normalizedManifest.enableReact) { + this.reactSourcePaths = unique([ + ...this.reactSourcePaths, + ...getManifestSourcePaths(normalizedManifest) + ]); + } + this.manifest.alias = { ...createWorkspaceAliases(packageName, normalizedManifest), ...normalizedManifest.alias, @@ -543,11 +561,17 @@ class WebpackConfigBuilder { }, module: { rules: [ - ...createDefaultRules(this.manifest, sourcePaths, this.localSourcePaths), + ...createDefaultRules(this.manifest, sourcePaths, this.reactSourcePaths), ...this.manifest.rules ] }, - plugins: [...this.manifest.plugins] + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'] + }), + ...this.manifest.plugins + ], + externals: this.manifest.externals }; if (Object.keys(this.manifest.snapshot).length > 0) { @@ -572,8 +596,8 @@ class WebpackConfigBuilder { } configuration.optimization = { - ...(configuration.optimization ?? {}), minimize: process.env.NODE_ENV === 'production', + ...(configuration.optimization ?? {}), minimizer: [ new TerserPlugin({ include: /\.min\.js$/ @@ -584,6 +608,10 @@ class WebpackConfigBuilder { if (targetingNode) { configuration.externalsPresets = {node: true}; configuration.externals = [nodeExternals()]; + // @ts-expect-error output must be definied + configuration.output.environment = { + nodePrefixForCoreModules: false // Backwards compatibility + }; } return configuration; diff --git a/packages/l10n/webpack.config.js b/packages/l10n/webpack.config.js index 554ca054..753f7ea4 100644 --- a/packages/l10n/webpack.config.js +++ b/packages/l10n/webpack.config.js @@ -8,6 +8,5 @@ const config = new WebpackConfigBuilder(Object.assign(manifest, { localeData: './src/locale-data.js' } })).get(); -config.devtool = 'cheap-module-source-map'; module.exports = config; diff --git a/packages/l10n/webpack.manifest.js b/packages/l10n/webpack.manifest.js index a54b43a7..c9e40f84 100644 --- a/packages/l10n/webpack.manifest.js +++ b/packages/l10n/webpack.manifest.js @@ -3,7 +3,7 @@ * @import { WebpackManifest } from '../infra'; */ -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { entry: './src/index.js', libraryName: 'l10n', diff --git a/packages/paint/webpack.config.js b/packages/paint/webpack.config.js index b7fb6caa..ee55a447 100644 --- a/packages/paint/webpack.config.js +++ b/packages/paint/webpack.config.js @@ -26,21 +26,21 @@ const library = new WebpackConfigBuilder({ entry: { 'scratch-paint': manifest.entry }, - distPath: path.resolve(__dirname, 'dist') + distPath: path.resolve(__dirname, 'dist'), + externals: { + '@turbowarp/nanolog': '@turbowarp/nanolog', + 'prop-types': 'prop-types', + 'react': 'react', + 'react-dom': 'react-dom', + 'react-intl': 'react-intl', + 'react-intl-redux': 'react-intl-redux', + 'react-popover': 'react-popover', + 'react-redux': 'react-redux', + 'react-responsive': 'react-responsive', + 'react-style-proptype': 'react-style-proptype', + 'react-tooltip': 'react-tooltip', + 'redux': 'redux' + } }).get(); -library.externals = { - '@turbowarp/nanolog': '@turbowarp/nanolog', - 'prop-types': 'prop-types', - 'react': 'react', - 'react-dom': 'react-dom', - 'react-intl': 'react-intl', - 'react-intl-redux': 'react-intl-redux', - 'react-popover': 'react-popover', - 'react-redux': 'react-redux', - 'react-responsive': 'react-responsive', - 'react-style-proptype': 'react-style-proptype', - 'react-tooltip': 'react-tooltip', - 'redux': 'redux' -}; module.exports = [playground, library]; diff --git a/packages/paint/webpack.manifest.js b/packages/paint/webpack.manifest.js index 9b461e68..3c3ff3de 100644 --- a/packages/paint/webpack.manifest.js +++ b/packages/paint/webpack.manifest.js @@ -3,7 +3,7 @@ * @import { WebpackManifest } from '../infra'; */ -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { entry: './src/index.js', libraryName: 'ScratchPaint', diff --git a/packages/render/webpack.config.js b/packages/render/webpack.config.js index 15a8abb1..96d112a4 100644 --- a/packages/render/webpack.config.js +++ b/packages/render/webpack.config.js @@ -1,13 +1,13 @@ -const CopyWebpackPlugin = require('copy-webpack-plugin'); const manifest = require('./webpack.manifest'); const WebpackConfigBuilder = require('../infra'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); + const createConfig = overrideManifest => { const config = new WebpackConfigBuilder({ ...manifest, ...overrideManifest }).get(); - config.devtool = 'cheap-module-source-map'; return config; }; diff --git a/packages/render/webpack.manifest.js b/packages/render/webpack.manifest.js index 88751e2a..ded4a127 100644 --- a/packages/render/webpack.manifest.js +++ b/packages/render/webpack.manifest.js @@ -3,7 +3,7 @@ * @import { WebpackManifest } from '../infra'; */ -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { libraryName: 'ClipCCRender', entry: './src/index.js', diff --git a/packages/storage/webpack.config.js b/packages/storage/webpack.config.js index 0afb6321..2dfa2e65 100644 --- a/packages/storage/webpack.config.js +++ b/packages/storage/webpack.config.js @@ -1,116 +1,46 @@ -const path = require('path'); const webpack = require('webpack'); -const TerserPlugin = require('terser-webpack-plugin'); -const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); -const baseConfig = { - mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - target: 'browserslist', - devtool: 'cheap-module-source-map', - module: { - rules: [ - { - include: [ - path.resolve(__dirname, 'src') - ], - test: /\.js$/, - loader: 'babel-loader', - options: { - presets: [ - ['@babel/preset-env', {targets: {browsers: ['last 3 versions', 'Safari >= 8', 'iOS >= 8']}}] - ] - } - }, - { - include: [ - path.resolve(__dirname, 'src') - ], - test: /\.([cm]?ts|tsx)$/, - loader: 'ts-loader' - }, - { - resourceQuery: '?arrayBuffer', - type: 'javascript/auto', - use: 'arraybuffer-loader' - } - ] - }, - resolve: { - extensions: ['.ts', '.js', '.json'], - fallback: { - Buffer: require.resolve('buffer/') - } - }, - optimization: { - splitChunks: false, - minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/, - terserOptions: { - sourceMap: true - } - }) - ] - }, - plugins: [ - new NodePolyfillPlugin() - ] -}; +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); -if (!process.env.CI) { - baseConfig.plugins.push(new webpack.ProgressPlugin()); -} +const createConfig = overrideManifest => { + const config = new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest + }).get(); -// Web-compatible -const webConfig = { - ...baseConfig, - output: { - library: { - name: 'ScratchStorage', - type: 'umd' - }, - path: path.resolve(__dirname, 'dist', 'web'), - clean: false - } + return config; }; -const webNonMinConfig = { - ...webConfig, +// Web-compatible +const webNonMin = createConfig({ + target: 'web', + distPath: './dist/web', entry: { - 'scratch-storage': path.join(__dirname, './src/index.ts') + 'scratch-storage': './src/index.ts' }, optimization: { minimize: false } -}; +}); -const webMinConfig = { - ...webConfig, +const webMin = createConfig({ + target: 'web', + distPath: './dist/web', entry: { - 'scratch-storage.min': path.join(__dirname, './src/index.ts') + 'scratch-storage.min': './src/index.ts' }, optimization: { minimize: true } -}; +}); // Node-compatible -const nodeConfig = { - ...baseConfig, +const node = createConfig({ target: 'node', + distPath: './dist/node', entry: { - 'scratch-storage': path.join(__dirname, './src/index.ts') - }, - output: { - library: { - type: 'commonjs2' - }, - environment: { - nodePrefixForCoreModules: false - }, - chunkFormat: 'commonjs', - path: path.resolve(__dirname, 'dist', 'node'), - clean: false + 'scratch-storage': './src/index.ts' }, externals: { 'base64-js': true, @@ -118,11 +48,15 @@ const nodeConfig = { 'localforage': true, 'fastestsmallesttextencoderdecoder': true }, - plugins: baseConfig.plugins.concat([ + plugins: [ new webpack.ProvidePlugin({ fetch: ['node-fetch', 'default'] }) - ]) -}; + ] +}); -module.exports = [webNonMinConfig, webMinConfig, nodeConfig]; +module.exports = [ + webNonMin, + webMin, + node +]; diff --git a/packages/storage/webpack.manifest.js b/packages/storage/webpack.manifest.js new file mode 100644 index 00000000..891c6eb0 --- /dev/null +++ b/packages/storage/webpack.manifest.js @@ -0,0 +1,22 @@ +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ +const webpack = require('webpack'); + +/** @satisfies {WebpackManifest} */ +const manifest = { + entry: './src/index.js', + libraryName: 'ScratchStorage', + target: 'browserslist', + rootPath: __dirname, + enableTs: true, + /** @type {NonNullable} */ + plugins: [] +}; + +if (!process.env.CI) { + manifest.plugins.push(new webpack.ProgressPlugin()); +} + +module.exports = manifest; diff --git a/packages/vm/package.json b/packages/vm/package.json index 31359b12..659734db 100644 --- a/packages/vm/package.json +++ b/packages/vm/package.json @@ -29,12 +29,14 @@ "version": "json -f package.json -I -e \"this.repository.sha = '$(git log -n1 --pretty=format:%H)'\"" }, "dependencies": { + "@turbowarp/nanolog": "^1.0.1", "@vernier/godirect": "1.8.3", "arraybuffer-loader": "1.0.8", "atob": "2.1.2", "btoa": "1.2.1", "canvas-toBlob": "1.0.0", "clipcc-parser": "3.2.0", + "clipcc-sb1-converter": "1.0.326", "decode-html": "2.0.0", "diff-match-patch": "1.0.4", "fastestsmallesttextencoderdecoder": "^1.0.22", @@ -42,9 +44,7 @@ "htmlparser2": "10.1.0", "immutable": "5.1.5", "jszip": "^3.10.1", - "@turbowarp/nanolog": "^1.0.1", "node-polyfill-webpack-plugin": "^3.0.0", - "clipcc-sb1-converter": "1.0.326", "scratch-translate-extension-languages": "1.0.7" }, "peerDependencies": { @@ -63,6 +63,7 @@ "clipcc-block": "3.2.0", "clipcc-l10n": "3.2.0", "clipcc-render": "3.2.0", + "clipcc-render-fonts": "1.0.256", "clipcc-storage": "3.2.0", "clipcc-svg-renderer": "2.5.48", "codingclip-worker-loader": "^3.0.10", @@ -76,7 +77,6 @@ "json": "^9.0.6", "lodash.defaultsdeep": "4.6.1", "pngjs": "7.0.0", - "clipcc-render-fonts": "1.0.256", "script-loader": "0.7.2", "stats.js": "0.17.0", "tap": "21.0.1", diff --git a/packages/vm/webpack.config.js b/packages/vm/webpack.config.js index 5074cde7..2b9fe293 100644 --- a/packages/vm/webpack.config.js +++ b/packages/vm/webpack.config.js @@ -27,18 +27,18 @@ const node = createConfig({ distPath: './dist/node', entry: { 'scratch-vm': './src/index.js' + }, + externals: { + 'decode-html': true, + 'format-message': true, + 'htmlparser2': true, + 'immutable': true, + 'jszip': true, + '@turbowarp/nanolog': true, + 'clipcc-parser': true, + 'socket.io-client': true } }); -node.externals = { - 'decode-html': true, - 'format-message': true, - 'htmlparser2': true, - 'immutable': true, - 'jszip': true, - '@turbowarp/nanolog': true, - 'clipcc-parser': true, - 'socket.io-client': true -}; // Playground const playground = createConfig({ diff --git a/packages/vm/webpack.manifest.js b/packages/vm/webpack.manifest.js index a0245cef..be39dc0d 100644 --- a/packages/vm/webpack.manifest.js +++ b/packages/vm/webpack.manifest.js @@ -6,7 +6,7 @@ const webpack = require('webpack'); const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); const {version} = require('../../package.json'); -/** @type {WebpackManifest} */ +/** @satisfies {WebpackManifest} */ const manifest = { libraryName: 'VirtualMachine', rootPath: __dirname, @@ -14,10 +14,9 @@ const manifest = { enableTs: true, sourcePaths: ['../render/src'], alias: { - 'text-encoding': 'fastestsmallesttextencoderdecoder', - 'clipcc-render': '../render/src/index.js', // @todo should move to workspacePackages when it gets migrated. - 'clipcc-audio': '../audio/src/index.js' // @todo should move to workspacePackages when it gets migrated. + 'text-encoding': 'fastestsmallesttextencoderdecoder' }, + workspacePackages: ['clipcc-render', 'clipcc-audio'], rules: [{ test: /\.mp3$/, type: 'asset/resource' From e67c2dc933fb6141e1a77716b0a67d4bfadd0b51 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 11 Mar 2026 23:29:58 +0800 Subject: [PATCH 05/10] :bug: fix: wrong webpack module rules Signed-off-by: SimonShiki --- packages/gui/scripts/block-message-loader.js | 2 -- packages/gui/webpack.config.js | 2 -- packages/gui/webpack.manifest.js | 2 +- packages/infra/src/index.js | 1 - packages/paint/webpack.manifest.js | 2 +- packages/vm/webpack.manifest.js | 5 +---- 6 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/gui/scripts/block-message-loader.js b/packages/gui/scripts/block-message-loader.js index 1b629926..7c456710 100644 --- a/packages/gui/scripts/block-message-loader.js +++ b/packages/gui/scripts/block-message-loader.js @@ -8,8 +8,6 @@ const path = require('node:path'); module.exports = function (/** @type {string} */ source) { if (!source.includes('export default')) return source; - console.log(123); - const messagePath = path.resolve(__dirname, '../../block/msg/messages.js'); const content = fs.readFileSync(messagePath, {encoding: 'utf-8'}); this.addDependency(messagePath); diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index 3848bb1f..a3eb8d09 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -190,6 +190,4 @@ if (process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist') configs.push(lib); } -console.dir(playground, {depth: 20}); - module.exports = configs; diff --git a/packages/gui/webpack.manifest.js b/packages/gui/webpack.manifest.js index 9cc187a6..818355d6 100644 --- a/packages/gui/webpack.manifest.js +++ b/packages/gui/webpack.manifest.js @@ -54,7 +54,7 @@ const base = { ], }; -if (process.env.NODE_ENV === 'development') { +if (process.env.NODE_ENV !== 'production') { base.rules.push({ test: /blocks-msgs\.js$/, include: [ diff --git a/packages/infra/src/index.js b/packages/infra/src/index.js index 36553173..4a3668d0 100644 --- a/packages/infra/src/index.js +++ b/packages/infra/src/index.js @@ -608,7 +608,6 @@ class WebpackConfigBuilder { if (targetingNode) { configuration.externalsPresets = {node: true}; configuration.externals = [nodeExternals()]; - // @ts-expect-error output must be definied configuration.output.environment = { nodePrefixForCoreModules: false // Backwards compatibility }; diff --git a/packages/paint/webpack.manifest.js b/packages/paint/webpack.manifest.js index 3c3ff3de..db44e77b 100644 --- a/packages/paint/webpack.manifest.js +++ b/packages/paint/webpack.manifest.js @@ -16,7 +16,7 @@ const manifest = { }, { test: /\.svg$/, - loader: 'svg-url-loader' + type: 'asset/inline' }] }; diff --git a/packages/vm/webpack.manifest.js b/packages/vm/webpack.manifest.js index be39dc0d..4418450d 100644 --- a/packages/vm/webpack.manifest.js +++ b/packages/vm/webpack.manifest.js @@ -17,10 +17,7 @@ const manifest = { 'text-encoding': 'fastestsmallesttextencoderdecoder' }, workspacePackages: ['clipcc-render', 'clipcc-audio'], - rules: [{ - test: /\.mp3$/, - type: 'asset/resource' - }], + rules: [], plugins: [ new NodePolyfillPlugin(), new webpack.DefinePlugin({ From 2edfaf48f0378e291bc428c363b144ac7cf82678 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 12 Mar 2026 09:10:17 +0800 Subject: [PATCH 06/10] :bug: fix: bump clipcc-sb1-convertor to fix import Signed-off-by: SimonShiki --- packages/vm/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vm/package.json b/packages/vm/package.json index 659734db..24671851 100644 --- a/packages/vm/package.json +++ b/packages/vm/package.json @@ -36,7 +36,7 @@ "btoa": "1.2.1", "canvas-toBlob": "1.0.0", "clipcc-parser": "3.2.0", - "clipcc-sb1-converter": "1.0.326", + "clipcc-sb1-converter": "1.0.327", "decode-html": "2.0.0", "diff-match-patch": "1.0.4", "fastestsmallesttextencoderdecoder": "^1.0.22", diff --git a/yarn.lock b/yarn.lock index 8cd5d65c..12cfd9c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6506,10 +6506,10 @@ clipcc-render-fonts@1.0.256: dependencies: base64-loader "^1.0.0" -clipcc-sb1-converter@1.0.326: - version "1.0.326" - resolved "https://registry.yarnpkg.com/clipcc-sb1-converter/-/clipcc-sb1-converter-1.0.326.tgz#b370eed171fc5c7644e4a1a40e48a6bd6463d5f6" - integrity sha512-t7qPJhHXtGw8+Z82msjNqfM7tzO/u9sdqgI8EQ7nVpqS+/q66tbbni8ff4EM6VFHlH6Yc3FfpM5FyH1chdu1Iw== +clipcc-sb1-converter@1.0.327: + version "1.0.327" + resolved "https://registry.yarnpkg.com/clipcc-sb1-converter/-/clipcc-sb1-converter-1.0.327.tgz#7d1567a8b636e4598eaee5a000f3d39902066d1b" + integrity sha512-OnmnpRWKImSKTA28gfxkdFVkZnGECFxGOJIFS0u3GJngG+QWTGYajA8J9eeRie9uXghj7QjRRdW818pNczofmg== dependencies: "@turbowarp/nanolog" "^1.0.1" fastestsmallesttextencoderdecoder "^1.0.22" From 1d84e78e4c48c5b1e928f51e796bc537c8442dfc Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 12 Mar 2026 09:17:19 +0800 Subject: [PATCH 07/10] :wrench: chore(vm): make canvas external Signed-off-by: SimonShiki --- packages/vm/webpack.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vm/webpack.config.js b/packages/vm/webpack.config.js index 2b9fe293..9ccf47a9 100644 --- a/packages/vm/webpack.config.js +++ b/packages/vm/webpack.config.js @@ -2,7 +2,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); const WebpackConfigBuilder = require('../infra'); const manifest = require('./webpack.manifest'); -const createConfig = (overrideManifest) => { +const createConfig = overrideManifest => { const config = new WebpackConfigBuilder({ ...manifest, ...overrideManifest @@ -36,7 +36,8 @@ const node = createConfig({ 'jszip': true, '@turbowarp/nanolog': true, 'clipcc-parser': true, - 'socket.io-client': true + 'socket.io-client': true, + 'canvas': true } }); From a9c001c3b4ca6d162ff0715b47a76a78d504cb3f Mon Sep 17 00:00:00 2001 From: Simon Shiki Date: Thu, 12 Mar 2026 10:48:48 +0800 Subject: [PATCH 08/10] :wrench: chore(vm): remove unneeded source paths --- packages/vm/webpack.manifest.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vm/webpack.manifest.js b/packages/vm/webpack.manifest.js index 4418450d..d76a6693 100644 --- a/packages/vm/webpack.manifest.js +++ b/packages/vm/webpack.manifest.js @@ -12,7 +12,6 @@ const manifest = { rootPath: __dirname, entry: './src/index.js', enableTs: true, - sourcePaths: ['../render/src'], alias: { 'text-encoding': 'fastestsmallesttextencoderdecoder' }, From c3e3f80c0321e686950e3349807513d895c24627 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 12 Mar 2026 12:40:10 +0800 Subject: [PATCH 09/10] :wrench: chore: refine infra Signed-off-by: SimonShiki --- packages/block/webpack.config.js | 2 +- packages/gui/webpack.config.js | 13 +- packages/infra/LICENSE | 2 +- packages/infra/README.md | 341 ++++++++++-------- packages/infra/package.json | 2 +- packages/infra/src/index.js | 60 +-- .../workspace-packages/alpha/package.json | 5 + .../alpha/webpack.manifest.js | 24 ++ .../workspace-packages/beta/package.json | 5 + .../beta/webpack.manifest.js | 22 ++ .../workspace-packages/gamma/package.json | 5 + .../gamma/webpack.manifest.js | 16 + packages/infra/test/target.test.js | 192 ++++++++++ packages/infra/test/targets.test.js | 133 ------- packages/infra/test/workspace.test.js | 128 +++++++ packages/storage/webpack.config.js | 2 +- 16 files changed, 624 insertions(+), 328 deletions(-) create mode 100644 packages/infra/test/fixtures/workspace-packages/alpha/package.json create mode 100644 packages/infra/test/fixtures/workspace-packages/alpha/webpack.manifest.js create mode 100644 packages/infra/test/fixtures/workspace-packages/beta/package.json create mode 100644 packages/infra/test/fixtures/workspace-packages/beta/webpack.manifest.js create mode 100644 packages/infra/test/fixtures/workspace-packages/gamma/package.json create mode 100644 packages/infra/test/fixtures/workspace-packages/gamma/webpack.manifest.js create mode 100644 packages/infra/test/target.test.js delete mode 100644 packages/infra/test/targets.test.js create mode 100644 packages/infra/test/workspace.test.js diff --git a/packages/block/webpack.config.js b/packages/block/webpack.config.js index f20859f3..4baa854f 100644 --- a/packages/block/webpack.config.js +++ b/packages/block/webpack.config.js @@ -54,7 +54,7 @@ const node = createConfig({ } }); -// Web-comptible +// Web-compatible const web = createConfig({ distPath: './dist/web', target: 'web' diff --git a/packages/gui/webpack.config.js b/packages/gui/webpack.config.js index a3eb8d09..608864b3 100644 --- a/packages/gui/webpack.config.js +++ b/packages/gui/webpack.config.js @@ -32,7 +32,7 @@ const configs = []; const fallbackAssetRule = { test: /\.(svg|png|wav|gif|jpg)$/, - resourceQuery: { not: [/raw/] }, + resourceQuery: {not: [/raw/]}, type: 'asset/inline' }; @@ -139,7 +139,7 @@ configs.push(playground); if (process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist') { const lib = createConfig({ target: 'web', - publicPath: STATIC_PATH, + publicPath: `${STATIC_PATH}/`, entry: { 'scratch-gui': './src/index.js' }, @@ -169,17 +169,14 @@ if (process.env.NODE_ENV === 'production' || process.env.BUILD_MODE === 'dist') ], optimization: { minimizer: [ - new TerserPlugin({ - include: /\.min\.js$/ - }), new ImageMinimizerPlugin({ minimizer: { implementation: ImageMinimizerPlugin.imageminMinify, options: { plugins: [ - ['gifsicle', { interlaced: true }], - ['jpegtran', { progressive: true }], - ['optipng', { optimizationLevel: 5 }] + ['gifsicle', {interlaced: true}], + ['jpegtran', {progressive: true}], + ['optipng', {optimizationLevel: 5}] ] } } diff --git a/packages/infra/LICENSE b/packages/infra/LICENSE index 0ebdcc96..ab7b64e4 100644 --- a/packages/infra/LICENSE +++ b/packages/infra/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) Scratch Foundation +Copyright (c) Clipteam All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/packages/infra/README.md b/packages/infra/README.md index f277fe3d..326e257a 100644 --- a/packages/infra/README.md +++ b/packages/infra/README.md @@ -1,202 +1,235 @@ -# scratch-webpack-configuration +# clipcc-infra -Shared configuration for Scratch's use of webpack +Provides shared infrastructure for other clipcc packages. Right now that mainly means a webpack configuration builder with a consistent set of defaults for JS, TypeScript, React, CSS modules, playground builds, and source-linked workspace packages. -## Usage +## Webpack -Add something like this to your `webpack.config.*js` file: +### Basic Usage -```javascript -import ScratchWebpackConfigBuilder from 'scratch-webpack-configuration'; +Add something like this to your `webpack.config.js` file: -const builder = new ScratchWebpackConfigBuilder( - { - rootPath: __dirname, - enableReact: true - }) - .setTarget('browserslist') - .addModuleRule({ - test: /\.css$/, - use: [/* CSS loaders */] - }) - .addPlugin(new CopyWebpackPlugin({ - patterns: [/* CopyWebpackPlugin patterns */] - }); +```javascript +// @ts-check +/** + * @import { WebpackManifest } from 'clipcc-infra'; + */ + +const WebpackConfigBuilder = require('clipcc-infra'); + +/** @type {WebpackManifest} */ +const manifest = { + rootPath: __dirname, + libraryName: 'my-library', + entry: './src/index.js', + enableReact: true, + enableTs: true, + plugins: [], + rules: [] +}; if (process.env.FOO === 'bar') { - builder.addPlugin(new MyCustomPlugin()); + manifest.plugins.push(new MyCustomPlugin()); + manifest.rules.push({ + test: /\.foo$/, + use: [/* FOO loaders */] + }); } +const builder = new WebpackConfigBuilder(manifest); + module.exports = builder.get(); ``` -Call `addModuleRule` and `addPlugin` as few or as many times as needed. If you need multiple configurations, you can -use `clone()` to share a base configuration and then add or override settings: - -```javascript -const baseConfig = new ScratchWebpackConfigBuilder({rootPath: __dirname, libraryName: 'my-library'}) - .addModuleRule({ - test: /\.foo$/, - use: [/* FOO loaders */] - }); - -const config1 = baseConfig.clone() - .setTarget('browserslist') - .merge({/* arbitrary configuration */}) - .addPlugin(new MyCustomPlugin('hi')); +This produces a webpack 5 configuration with sensible defaults for clipcc packages. Custom plugins, aliases, optimization settings, snapshot settings, and module rules are merged into that generated configuration instead of replacing it wholesale. -const config2 = baseConfig.clone() - .setTarget('node') - .addPlugin(new MyCustomPlugin('hello')); +### Workspace Packages -module.exports = [ - config1.get(), - config2.get() -]; -``` +If your project is part of a monorepo, add package names to `workspacePackages` so other workspace packages can be consumed from source safely. This is useful when related packages are under active development and you want webpack to compile them directly instead of relying on a prebuilt local install. -To load another workspace package from source without leaking that package's -loader rules into the current package, register it explicitly: +To make a package consumable from source, add a `webpack.manifest.js` file at the package root: ```javascript -const builder = new ScratchWebpackConfigBuilder({ - rootPath: __dirname, - enableReact: true, - enableTs: true -}) - .addWorkspacePackage({ - name: 'clipcc-block', - rootPath: path.resolve(__dirname, '../block'), - moduleRules: [{ - test: /\.css$/, - use: 'raw-loader' - }] - }) - .setTarget('browserslist'); +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ + +/** @satisfies {WebpackManifest} */ +const manifest = { + libraryName: 'ClipCCRender', + entry: './src/index.js', + rootPath: __dirname, + enableTs: true +}; + +module.exports = manifest; ``` -The builder will: - -- resolve `clipcc-block` from the package `src/` directory -- include that source tree in the default JS/TS transpilation rules -- wrap its extra `module.rules` so they only apply to files inside that package - -## What it does +That manifest is what downstream packages read, so keep `entry` aligned with the public source entry for the package. -- Sets up a default configuration that is suitable for most Scratch projects - - Use `enableReact` to enable React support - - Target `node` or `browserslist` (more targets will be added as needed) -- Adds `babel-loader` with the `@babel/preset-env` preset - - Adds `@babel/preset-react` if React support is enabled -- Adds `ts-loader` when TypeScript support is enabled - - By default it uses `transpileOnly: true` and `allowTsInNodeModules: true` - - Override this with `tsLoaderOptions`, or disable the defaults with `useDefaultTsLoaderOptions: false` -- Adds target-specific presets for `webpack` 5's `externals` and `externalsPresets` settings -- Target-specific output directory under `dist/` - - `browserslist` builds to `dist/web/` - - `node` builds to `dist/node/` -- Supports merging in arbitrary configuration with `merge({...})` -- Can register workspace packages from source with package-scoped webpack rules +Then make the package's `webpack.config.js` build from that manifest so it still works when built on its own: -### Asset Modules +```javascript +const manifest = require('./webpack.manifest'); +const WebpackConfigBuilder = require('../infra'); + +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +const createConfig = overrideManifest => new WebpackConfigBuilder({ + ...manifest, + ...overrideManifest +}).get(); + +// Playground +const playground = createConfig({ + target: 'web', + distPath: './playground', + entry: { + playground: './src/playground/playground.js', + queryPlayground: './src/playground/queryPlayground.js' + }, + playground: 8361 +}); + +playground.plugins.push( + new CopyWebpackPlugin({ + patterns: [{ + context: 'src/playground', + from: '*.+(html|css)' + }] + }) +); + +// Web-compatible +const web = createConfig({ + target: 'web', + distPath: './dist/web', + entry: { + 'scratch-render': './src/index.js', + 'scratch-render.min': './src/index.js' + } +}); + +// Node-compatible +const node = createConfig({ + target: 'node' +}); + +module.exports = [playground, web, node]; +``` -This configuration makes webpack 5's [Asset Modules](https://webpack.js.org/guides/asset-modules/) available through -resource queries parameters: +Other packages can then consume that package from source by listing it in `workspacePackages`: -```js -import myImage from './my-image.png?asset'; // Use `asset` (let webpack decide) -import myImage from './my-image.png?resource'; // Use `asset/resource`, similar to `file-loader` -import myImage from './my-image.png?inline'; // Use `asset/inline`, similar to `url-loader` -import myImage from './my-image.png?source'; // Use `asset/source`, similar to `raw-loader` +```javascript +// @ts-check +/** + * @import { WebpackManifest } from '../infra'; + */ +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +/** @satisfies {WebpackManifest} */ +const base = { + libraryName: 'Consumer', + entry: './src/index.js', + rootPath: __dirname, + enableReact: true, + enableTs: true, + workspacePackages: [ + 'clipcc-render' + ], + plugins: [ + new CopyWebpackPlugin({ + /* ... */ + }) + ] +}; + +module.exports = base; ``` -You can also use `file` for `asset/resource`, `url` for `asset/inline`, and `raw` for `asset/source`, to make it clear -which loader you're replacing. +When a package is listed in `workspacePackages`, the builder will: -## API +1. Add an exact-match alias from `package-name$` to that package's entry file, so `import 'package-name'` resolves to its declared source entry. +2. Add a prefix alias from `package-name` to that package's source directory, so `import 'package-name/foo'` resolves inside its source tree. +3. Expand the default JS, TS, and React CSS handling to include that package's source paths. +4. Merge the package's own aliases, rules, and snapshot configuration into the generated config. +5. Recursively load its own `workspacePackages`, so nested source dependencies can also be compiled from source. -### `new ScratchWebpackConfigBuilder(options)` +### Default Behavior -Creates a new `ScratchWebpackConfigBuilder` instance. +The builder currently does these things: -#### `options` +- Resolves `entry`, `srcPath`, `distPath`, rule `include` and `exclude` paths, alias values, and snapshot paths relative to `rootPath`. +- Uses `cheap-module-source-map` by default for `devtool`. +- Uses `target: 'web'` by default. +- Emits UMD output for non-node targets and CommonJS output for node-like targets. +- Adds `babel-loader` with `@babel/preset-env` for JS sources. +- Adds `@babel/preset-react` when `enableReact` is enabled. +- Adds `ts-loader` with `transpileOnly: true` when `enableTs` is enabled. +- Adds CSS module handling for `.css` files under React source paths when `enableReact` is enabled. +- Injects `Buffer` through `webpack.ProvidePlugin`. +- Enables `devServer` when `playground` is set. +- Preserves user-supplied `optimization` and appends a terser pass for files that end with `.min.js`. +- Enables `splitChunks.chunks = 'async'` when `shouldSplitChunks` is enabled. +- Sets `externalsPresets.node = true` and disables `nodePrefixForCoreModules` for node-like targets. -Required: +### Asset Handling -- `rootPath` (string, required): The root path of the project. This is used to establish defaults for other paths. +The built-in rules currently add only a few asset-oriented behaviors: -Optional: - -- `distPath` (string, default: `path.join(rootPath, 'dist')`): The path to the output directory. Defaults to `dist` -- `enableReact` (boolean, default: `false`): Whether to enable React support. Adds `.jsx` to the list of extensions - to process, and adjusts Babel settings. -- `libraryName` (string, default: `undefined`): If set, configures a default entry point and output library name. - under the root path. -- `srcPath` (string, default: `path.join(rootPath, 'src')`): The path to the source directory. Defaults to `src` - under the root path. -- `enableTs` (boolean, default: `false`): Whether to enable TypeScript support. -- `sourcePaths` (`Array`, default: `[]`): Additional source roots to process with the default JS/TS rules. -- `tsLoaderOptions` (object, default: ClipCC defaults): Extra options to merge into `ts-loader`. -- `useDefaultTsLoaderOptions` (boolean, default: `true`): Whether to keep ClipCC's default `ts-loader` options. +```js +import firmware from './firmware.hex'; +import bytes from './sound.wav?arrayBuffer'; +import text from './template.svg?raw'; +``` -### `builder.addWorkspacePackage(options)` +- `.hex` files are emitted as inline `data:` URLs using base64 text content. +- `?arrayBuffer` uses `arraybuffer-loader`. +- `?raw` uses webpack's `asset/source` behavior. -Registers a workspace package so it can be consumed from source safely. +If you need additional asset module behavior such as `asset/resource` or `asset/inline` for other file types, add a custom rule in your manifest. -- `name` or `alias`: The resolve alias to register. -- `rootPath`: The package root. Defaults `srcPath` to `path.join(rootPath, 'src')`. -- `srcPath`: The package source directory if it is not under `src/`. -- `aliasTarget`: Custom alias target. Defaults to `srcPath`. -- `config`: Existing webpack config for the package. The builder selectively merges `module.rules`, `resolve`, and `snapshot`. -- `moduleRules`: Extra rules for the package. These are scoped to the package source path. -- `includeInDefaultLoaders` (boolean, default: `true`): Whether the builder's JS/TS rules should process this package. +### API -## Recommended Configuration +#### `new WebpackConfigBuilder(manifest: WebpackManifest)` -### Package exports +Creates a builder instance and normalizes the manifest immediately. Any packages listed in `workspacePackages` are resolved and merged during construction. -_The `exports` field in `package.json`_ +Required manifest fields: -Most `project.json` files specify a `main` entry point, and some specify `browser` as well. Newer versions of Node -support the `exports` field as well. If both are present, `exports` will take precedence. +- `entry`: The webpack entry definition for the package. +- `libraryName`: The UMD global name used for non-node targets. +- `rootPath`: The base directory used to resolve relative paths in the manifest. -For more information about `exports`, see: , especially the "Target -environment" section. +Optional manifest fields: -Unfortunately, plenty of tools don't support `exports` yet, and some that do exhibit some surprising quirks. +- `target`: Webpack target. Node-like targets switch output to CommonJS and enable `externalsPresets.node`. +- `devTool`: Source map mode for the generated config. Defaults to `cheap-module-source-map`. +- `srcPath`: Main source directory. Defaults to `./src`. +- `distPath`: Output directory for the bundle. Defaults to `./dist`. +- `publicPath`: Runtime base URL for emitted assets. Defaults to `/`. +- `sourcePaths`: Additional source directories that should go through the default JS and TS pipeline. +- `enableReact`: Enables React JS transpilation and CSS module handling for React source paths. +- `enableTs`: Enables `ts-loader` for TypeScript and TSX entries. +- `shouldSplitChunks`: Enables async chunk splitting under `optimization.splitChunks`. +- `rules`: Additional webpack rules appended after the built-in rules. +- `plugins`: Additional webpack plugins appended after the built-in `Buffer` provider. +- `alias`: Extra `resolve.alias` entries. These can override aliases inherited from workspace packages. +- `snapshot`: Snapshot configuration merged into the final webpack config. +- `playground`: Enables `devServer`; pass `true` to use `PORT` or `auto`, or pass a number to force a port. +- `externals`: Webpack externals passed through to the final config. +- `optimization`: Extra optimization settings merged into the final config before the default `.min.js` terser entry is appended. +- `workspacePackages`: Package names to consume from source through `webpack.manifest.js`. -Here's what I currently recommend for a project with only one entry point: +#### `builder.addWorkspacePackage(packageName: string)` -```json -{ - "main": "./dist/node/foo.js", - "browser": "./dist/web/foo.js", - "exports": { - "webpack": "./src/index.js", - "browser": "./dist/web/foo.js", - "node": "./dist/node/foo.js", - "default": "./src/index.js" - }, -} -``` +Loads a package manifest from `packageName/webpack.manifest.js` and merges its source-aware aliases, rules, snapshot settings, and recursive workspace package dependencies into the current builder. -- `main` supports older Node as well as `jest` -- `browser` is present for completeness; I haven't found it strictly necessary -- `exports.webpack` is the entry point for Webpack - - `webpack` will grab the first item under `exports` matching its conditions, including `browser`, so I recommend - listing `exports.webpack` first in the `exports` object - - this allows (for example) `scratch-gui` to build `scratch-vm` from source rather than using the prebuilt version, - resulting in more optimal output and preventing version conflicts due to bundled dependencies -- `exports.default` makes `eslint` happy -- `exports.browser` and `exports.node` prevent `exports`-aware tools from using `exports.default` for all contexts +#### `builder.get(): Configuration` -Note that using `src/index.js` for the `webpack` and `default` exports means that the NPM package must include `src`. +Builds and returns the final webpack configuration object. -### `browserslist` target +#### Browser Targets -While it could be handy to include `browserslist` configuration in this package, there are tools other than `webpack` -that should use the same `browserslist` configuration. For that reason, I recommend configuring `browserslist` in -your `package.json` file or in a top-level `.browserslistrc` file. +This package does not manage a shared `browserslist` definition. If your Babel and other tooling need browser targeting, configure `browserslist` in your package's `package.json` or a top-level `.browserslistrc` file so the same target matrix can be reused everywhere. -The Scratch system requirements determine the browsers we should target. That information can be found here: - diff --git a/packages/infra/package.json b/packages/infra/package.json index 5b25bfc1..cfd46411 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -23,7 +23,7 @@ "bugs": { "url": "https://github.com/Clipteam/clipcc/issues" }, - "homepage": "https://github.com/Clipteamclipcc#readme", + "homepage": "https://github.com/Clipteam/clipcc#readme", "dependencies": { "webpack-node-externals": "^3.0.0" }, diff --git a/packages/infra/src/index.js b/packages/infra/src/index.js index 4a3668d0..7ec362c0 100644 --- a/packages/infra/src/index.js +++ b/packages/infra/src/index.js @@ -5,7 +5,6 @@ const fs = require('fs'); const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); -const nodeExternals = require('webpack-node-externals'); /** @typedef {import('webpack').Configuration} Configuration */ /** @typedef {import('webpack').RuleSetRule} RuleSetRule */ @@ -29,33 +28,34 @@ const nodeExternals = require('webpack-node-externals'); * }} ManifestRule */ /** - * @typedef {{ - * entry: EntryConfig, - * libraryName: string, - * target?: Configuration['target'], - * devTool?: Configuration['devtool'], - * rootPath: string, - * srcPath?: string, - * distPath?: string, - * publicPath?: string, - * sourcePaths?: string[], - * enableReact?: boolean, - * enableTs?: boolean, - * shouldSplitChunks?: boolean, - * rules?: ManifestRule[], - * plugins?: Configuration['plugins'], - * alias?: Record, - * snapshot?: SnapshotConfig, - * playground?: boolean | number, - * externals?: Configuration['externals'], - * workspacePackages?: string[] - * }} WebpackManifest + * Manifest consumed by {@link WebpackConfigBuilder} to generate the final webpack configuration. + * + * @typedef {object} WebpackManifest + * @property {EntryConfig} entry Webpack entry definition. Relative string and array entries are resolved from `rootPath`. The first resolved entry is also used as the exact-match alias target when this package is consumed through `workspacePackages`. + * @property {string} libraryName Library name for non-node targets. It becomes `output.library.name` when the generated artifact is emitted as UMD. + * @property {Configuration['target']=} target Webpack target. Node-like targets emit `commonjs2` output and enable `externalsPresets.node`; other targets emit UMD bundles. + * @property {Configuration['devtool']=} devTool Source map mode for the generated config. Defaults to `cheap-module-source-map`. + * @property {string} rootPath Base path used to resolve relative entries, source paths, rule conditions, aliases, and snapshot paths. + * @property {string=} srcPath Main source directory for the package. Defaults to `src` and is included in the default transpilation rules. + * @property {string=} distPath Output directory for the generated bundle. Defaults to `dist` and becomes `output.path`. + * @property {string=} publicPath Runtime base URL for emitted assets and chunks. Defaults to `/` and becomes `output.publicPath`. + * @property {string[]=} sourcePaths Additional source directories that should be processed by the default JS and TS rules alongside `srcPath`. + * @property {boolean=} enableReact Enables React support. This adds `@babel/preset-react` and enables CSS module handling for React source paths. + * @property {boolean=} enableTs Enables TypeScript support. This adds `ts-loader` with `transpileOnly: true` for `.ts` and `.tsx` files under the configured source paths. + * @property {boolean=} shouldSplitChunks Enables async chunk splitting by setting `optimization.splitChunks.chunks` to `async`. + * @property {ManifestRule[]=} rules Additional webpack rules appended after the built-in rules. Nested `rules`, `oneOf`, `include`, and `exclude` paths are normalized from `rootPath`. + * @property {Configuration['plugins']=} plugins Additional webpack plugins appended after the built-in `webpack.ProvidePlugin` that injects `Buffer`. + * @property {Record=} alias Extra `resolve.alias` entries. Relative paths are resolved from `rootPath`; explicit aliases in the current manifest override inherited workspace-package aliases. + * @property {SnapshotConfig=} snapshot Webpack snapshot configuration merged into the final config. Path arrays are normalized from `rootPath` and merged across workspace packages. + * @property {boolean | number=} playground Enables `devServer` output for local playground builds. `true` uses `process.env.PORT` or `auto`; a number forces a specific port. + * @property {Configuration['externals']=} externals Webpack externals passed through to the final config. This changes which dependencies are bundled into the emitted artifact. + * @property {Configuration['optimization']=} optimization Extra optimization settings merged into the generated config before the default `.min.js` terser minimizer is appended. + * @property {string[]=} workspacePackages Package names to resolve through `packageName/webpack.manifest.js`. Their source paths, aliases, rules, and snapshot settings are merged so they can be consumed directly from source. */ const DEFAULT_CHUNK_FILENAME = 'chunks/[name].js'; const DEFAULT_TS_LOADER_OPTIONS = { - transpileOnly: true, - allowTsInNodeModules: true + transpileOnly: true }; /** @@ -309,7 +309,8 @@ const normalizeManifest = manifest => { target: manifest.target ?? 'web', playground: manifest.playground ?? false, workspacePackages: manifest.workspacePackages ?? [], - externals: manifest.externals ?? {} + externals: manifest.externals ?? {}, + optimization: manifest.optimization ?? {} }; }; @@ -550,7 +551,7 @@ class WebpackConfigBuilder { const configuration = { context: this.manifest.rootPath, mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', - devtool: 'cheap-module-source-map', + devtool: this.manifest.devTool, target: this.manifest.target, entry: this.manifest.entry, output, @@ -571,6 +572,7 @@ class WebpackConfigBuilder { }), ...this.manifest.plugins ], + optimization: this.manifest.optimization, externals: this.manifest.externals }; @@ -580,7 +582,7 @@ class WebpackConfigBuilder { if (this.manifest.shouldSplitChunks) { configuration.optimization = { - ...(configuration.optimization ?? {}), + ...configuration.optimization, splitChunks: { chunks: 'async' } @@ -597,8 +599,9 @@ class WebpackConfigBuilder { configuration.optimization = { minimize: process.env.NODE_ENV === 'production', - ...(configuration.optimization ?? {}), + ...configuration.optimization, minimizer: [ + ...configuration.optimization?.minimizer ?? [], new TerserPlugin({ include: /\.min\.js$/ }) @@ -607,7 +610,6 @@ class WebpackConfigBuilder { if (targetingNode) { configuration.externalsPresets = {node: true}; - configuration.externals = [nodeExternals()]; configuration.output.environment = { nodePrefixForCoreModules: false // Backwards compatibility }; diff --git a/packages/infra/test/fixtures/workspace-packages/alpha/package.json b/packages/infra/test/fixtures/workspace-packages/alpha/package.json new file mode 100644 index 00000000..73a541be --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/alpha/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-alpha", + "version": "1.0.0", + "main": "src/index.js" +} diff --git a/packages/infra/test/fixtures/workspace-packages/alpha/webpack.manifest.js b/packages/infra/test/fixtures/workspace-packages/alpha/webpack.manifest.js new file mode 100644 index 00000000..06f25ba4 --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/alpha/webpack.manifest.js @@ -0,0 +1,24 @@ +module.exports = { + rootPath: __dirname, + libraryName: 'AlphaFixture', + entry: ['./src/index.js', './src/polyfill.js'], + enableReact: true, + enableTs: true, + sourcePaths: ['./alpha-extra'], + alias: { + '@workspace-shared': './alpha-alias' + }, + rules: [{ + test: /\.alpha$/, + include: './alpha-include', + oneOf: [{ + test: /\.alpha-inner$/, + include: './alpha-inner' + }] + }], + snapshot: { + immutablePaths: ['./alpha-immutable'], + unmanagedPaths: ['./alpha-unmanaged'] + }, + workspacePackages: ['test-workspace-beta'] +}; diff --git a/packages/infra/test/fixtures/workspace-packages/beta/package.json b/packages/infra/test/fixtures/workspace-packages/beta/package.json new file mode 100644 index 00000000..4caf8520 --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/beta/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-beta", + "version": "1.0.0", + "main": "src/beta-entry.js" +} diff --git a/packages/infra/test/fixtures/workspace-packages/beta/webpack.manifest.js b/packages/infra/test/fixtures/workspace-packages/beta/webpack.manifest.js new file mode 100644 index 00000000..e52d6058 --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/beta/webpack.manifest.js @@ -0,0 +1,22 @@ +module.exports = { + rootPath: __dirname, + libraryName: 'BetaFixture', + entry: { + beta: { + import: ['./src/beta-entry.js', './src/beta-helper.js'] + } + }, + sourcePaths: ['./beta-extra'], + alias: { + '@beta-only': './beta-alias' + }, + rules: [{ + test: /\.beta$/, + exclude: './beta-exclude' + }], + snapshot: { + immutablePaths: ['./beta-immutable'], + managedPaths: ['./beta-managed'] + }, + workspacePackages: ['test-workspace-gamma'] +}; diff --git a/packages/infra/test/fixtures/workspace-packages/gamma/package.json b/packages/infra/test/fixtures/workspace-packages/gamma/package.json new file mode 100644 index 00000000..5b936de1 --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/gamma/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-gamma", + "version": "1.0.0", + "main": "src/gamma-entry.js" +} diff --git a/packages/infra/test/fixtures/workspace-packages/gamma/webpack.manifest.js b/packages/infra/test/fixtures/workspace-packages/gamma/webpack.manifest.js new file mode 100644 index 00000000..d1307ef7 --- /dev/null +++ b/packages/infra/test/fixtures/workspace-packages/gamma/webpack.manifest.js @@ -0,0 +1,16 @@ +module.exports = { + rootPath: __dirname, + libraryName: 'GammaFixture', + entry: './src/gamma-entry.js', + enableReact: true, + alias: { + '@gamma-only': './gamma-alias' + }, + rules: [{ + test: /\.gamma$/, + include: './gamma-include' + }], + snapshot: { + unmanagedPaths: ['./gamma-unmanaged'] + } +}; diff --git a/packages/infra/test/target.test.js b/packages/infra/test/target.test.js new file mode 100644 index 00000000..5a591362 --- /dev/null +++ b/packages/infra/test/target.test.js @@ -0,0 +1,192 @@ +const path = require('path'); +const webpack = require('webpack'); +const TerserPlugin = require('terser-webpack-plugin'); + +const WebpackConfigBuilder = require('../src'); + +const getRuleByLoader = (rules, loader) => rules.find(rule => rule.loader === loader); + +const getAssetRule = (rules, predicate) => rules.find(predicate); + +describe('WebpackConfigBuilder targets', () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalPort = process.env.PORT; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + process.env.PORT = originalPort; + }); + + test('builds the expected web configuration defaults and feature flags', () => { + const rootPath = path.join(__dirname, 'fixtures', 'target-consumer'); + + process.env.NODE_ENV = 'production'; + process.env.PORT = '4321'; + + const config = new WebpackConfigBuilder({ + rootPath, + libraryName: 'TargetFixture', + entry: { + main: './src/index.js', + worker: { + import: ['./src/worker.js', './src/runtime.js'] + } + }, + enableReact: true, + enableTs: true, + sourcePaths: ['./extras'], + alias: { + '@shared': './shared' + }, + rules: [{ + test: /\.custom$/, + include: './custom', + oneOf: [{ + test: /\.custom-child$/, + include: './nested' + }] + }], + shouldSplitChunks: true, + optimization: { + moduleIds: 'deterministic' + }, + playground: true, + publicPath: '/static/' + }).get(); + + expect(config.context).toBe(rootPath); + expect(config.mode).toBe('production'); + expect(config.devtool).toBe('cheap-module-source-map'); + expect(config.entry).toEqual({ + main: path.join(rootPath, 'src', 'index.js'), + worker: { + import: [ + path.join(rootPath, 'src', 'worker.js'), + path.join(rootPath, 'src', 'runtime.js') + ] + } + }); + expect(config.output).toMatchObject({ + path: path.join(rootPath, 'dist'), + publicPath: '/static/', + filename: '[name].js', + chunkFilename: 'chunks/[name].js', + library: { + name: 'TargetFixture', + type: 'umd' + } + }); + expect(config.resolve).toEqual({ + extensions: ['.ts', '.js', '.tsx', '.jsx'], + alias: { + '@shared': path.join(rootPath, 'shared') + }, + symlinks: false + }); + + const tsRule = getRuleByLoader(config.module.rules, 'ts-loader'); + expect(tsRule).toMatchObject({ + test: /\.([cm]?ts|tsx)$/, + loader: 'ts-loader', + options: { + transpileOnly: true + }, + include: [ + path.join(rootPath, 'src'), + path.join(rootPath, 'extras') + ] + }); + + const babelRule = getRuleByLoader(config.module.rules, 'babel-loader'); + expect(babelRule).toMatchObject({ + test: /\.[cm]?jsx?$/, + loader: 'babel-loader', + include: [ + path.join(rootPath, 'src'), + path.join(rootPath, 'extras') + ] + }); + expect(babelRule.options).toMatchObject({ + babelrc: false, + presets: ['@babel/preset-env', '@babel/preset-react'] + }); + + const cssRule = getAssetRule(config.module.rules, rule => Array.isArray(rule.use)); + expect(cssRule).toMatchObject({ + test: /\.css$/, + include: [ + path.join(rootPath, 'src'), + path.join(rootPath, 'extras') + ] + }); + expect(cssRule.use.map(loader => loader.loader)).toEqual([ + 'style-loader', + 'css-loader', + 'postcss-loader' + ]); + + const customRule = config.module.rules.find(rule => String(rule.test) === String(/\.custom$/)); + expect(customRule).toMatchObject({ + include: path.join(rootPath, 'custom') + }); + expect(customRule.oneOf[0]).toMatchObject({ + test: /\.custom-child$/, + include: path.join(rootPath, 'nested') + }); + + expect(getAssetRule(config.module.rules, rule => String(rule.test) === String(/\.hex$/))).toMatchObject({ + type: 'asset/inline' + }); + expect(getAssetRule(config.module.rules, rule => String(rule.resourceQuery) === String(/raw/))).toMatchObject({ + type: 'asset/source' + }); + expect(getAssetRule(config.module.rules, rule => rule.resourceQuery === '?arrayBuffer')).toMatchObject({ + type: 'javascript/auto', + use: 'arraybuffer-loader' + }); + + expect(config.plugins[0]).toBeInstanceOf(webpack.ProvidePlugin); + expect(config.devServer).toEqual({ + static: path.join(rootPath, 'dist'), + host: '0.0.0.0', + port: '4321' + }); + expect(config.optimization).toMatchObject({ + minimize: true, + moduleIds: 'deterministic', + splitChunks: { + chunks: 'async' + } + }); + expect(config.optimization.minimizer).toHaveLength(1); + expect(config.optimization.minimizer[0]).toBeInstanceOf(TerserPlugin); + }); + + test('builds node targets with commonjs output and node presets', () => { + const rootPath = path.join(__dirname, 'fixtures', 'node-consumer'); + + process.env.NODE_ENV = 'development'; + + const config = new WebpackConfigBuilder({ + rootPath, + libraryName: 'NodeFixture', + entry: './src/index.js', + target: 'node18', + enableTs: true + }).get(); + + expect(config.mode).toBe('development'); + expect(config.entry).toBe(path.join(rootPath, 'src', 'index.js')); + expect(config.output.library).toEqual({ + type: 'commonjs2' + }); + expect(config.externalsPresets).toEqual({ + node: true + }); + expect(config.output.environment).toEqual({ + nodePrefixForCoreModules: false + }); + expect(config.resolve.extensions).toEqual(['.ts', '.js']); + expect(config.devServer).toBeUndefined(); + }); +}); diff --git a/packages/infra/test/targets.test.js b/packages/infra/test/targets.test.js deleted file mode 100644 index aec73446..00000000 --- a/packages/infra/test/targets.test.js +++ /dev/null @@ -1,133 +0,0 @@ -const path = require('path'); - -const webpack = require('webpack'); - -const ScratchWebpackConfigBuilder = require('../src/index.cjs'); - -const common = { - libraryName: 'test-library', - rootPath: path.resolve(__dirname) -}; - -describe('generating configurations for specific targets', () => { - it('should should generate a valid configuration without a target', () => { - const genericConfig = new ScratchWebpackConfigBuilder(common) - .get(); - expect(genericConfig).not.toHaveProperty('target'); - expect(() => webpack.validate(genericConfig)).not.toThrow(); - }); - - it('should should generate a valid `node` configuration', () => { - const nodeConfig = new ScratchWebpackConfigBuilder(common) - .setTarget('node') - .get(); - expect(nodeConfig).toMatchObject({target: 'node'}); - expect(() => webpack.validate(nodeConfig)).not.toThrow(); - }); - - it('should should generate a valid `browserslist` configuration', () => { - const webConfig = new ScratchWebpackConfigBuilder(common) - .setTarget('browserslist') - .get(); - expect(webConfig).toMatchObject({target: 'browserslist'}); - expect(() => webpack.validate(webConfig)).not.toThrow(); - }); -}); - -describe('TypeScript support', () => { - it('uses a dedicated ts-loader rule with ClipCC defaults', () => { - const externalSourcePath = path.resolve(__dirname, 'external-src'); - const config = new ScratchWebpackConfigBuilder({ - ...common, - enableTs: true, - sourcePaths: [externalSourcePath] - }).get(); - - const jsRule = config.module.rules.find(rule => rule.loader === 'babel-loader'); - const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); - - expect(jsRule.test.test('example.js')).toBe(true); - expect(jsRule.test.test('example.ts')).toBe(false); - expect(tsRule).toMatchObject({ - options: { - transpileOnly: true, - allowTsInNodeModules: true - } - }); - expect(tsRule.include).toEqual(expect.arrayContaining([ - path.resolve(__dirname, 'src'), - externalSourcePath - ])); - expect(() => webpack.validate(config)).not.toThrow(); - }); - - it('lets callers override the default ts-loader options', () => { - const config = new ScratchWebpackConfigBuilder({ - ...common, - enableTs: true, - tsLoaderOptions: { - transpileOnly: false, - projectReferences: true - }, - useDefaultTsLoaderOptions: false - }).get(); - - const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); - - expect(tsRule.options).toEqual({ - transpileOnly: false, - projectReferences: true - }); - expect(() => webpack.validate(config)).not.toThrow(); - }); -}); - -describe('workspace package support', () => { - it('scopes package-specific rules to the workspace package source path', () => { - const blockRootPath = path.resolve(__dirname, '../block'); - const blockSrcPath = path.resolve(blockRootPath, 'src'); - const managedPathPattern = /^(.+?[\\/]node_modules[\\/](?!clipcc-block).+?)[\\/]/; - const config = new ScratchWebpackConfigBuilder({ - ...common, - enableReact: true, - enableTs: true - }) - .addWorkspacePackage({ - name: 'clipcc-block', - rootPath: blockRootPath, - config: { - module: { - rules: [{ - test: /\.css$/, - use: 'raw-loader' - }] - }, - resolve: { - alias: { - 'clipcc-block/msg': path.resolve(blockSrcPath, 'msg') - }, - extensions: ['.block.css'], - symlinks: false - }, - snapshot: { - managedPaths: [managedPathPattern] - } - } - }) - .get(); - - const jsRule = config.module.rules.find(rule => rule.loader === 'babel-loader'); - const tsRule = config.module.rules.find(rule => rule.loader === 'ts-loader'); - const scopedRule = config.module.rules.find(rule => rule.include === blockSrcPath && Array.isArray(rule.rules)); - - expect(config.resolve.alias['clipcc-block']).toBe(blockSrcPath); - expect(config.resolve.alias['clipcc-block/msg']).toBe(path.resolve(blockSrcPath, 'msg')); - expect(config.resolve.extensions).toEqual(expect.arrayContaining(['.block.css'])); - expect(config.resolve.symlinks).toBe(false); - expect(config.snapshot.managedPaths).toContain(managedPathPattern); - expect(jsRule.include).toEqual(expect.arrayContaining([blockSrcPath])); - expect(tsRule.include).toEqual(expect.arrayContaining([blockSrcPath])); - expect(scopedRule.rules).toEqual([{test: /\.css$/, use: 'raw-loader'}]); - expect(() => webpack.validate(config)).not.toThrow(); - }); -}); diff --git a/packages/infra/test/workspace.test.js b/packages/infra/test/workspace.test.js new file mode 100644 index 00000000..aa3c0ff6 --- /dev/null +++ b/packages/infra/test/workspace.test.js @@ -0,0 +1,128 @@ +const path = require('path'); +const Module = require('module'); + +const WebpackConfigBuilder = require('../src'); + +const originalResolveFilename = Module._resolveFilename; + +const fixtureDir = path.join(__dirname, 'fixtures', 'workspace-packages'); + +const fixturePackageJsonPaths = { + 'test-workspace-alpha/package.json': path.join(fixtureDir, 'alpha', 'package.json'), + 'test-workspace-beta/package.json': path.join(fixtureDir, 'beta', 'package.json'), + 'test-workspace-gamma/package.json': path.join(fixtureDir, 'gamma', 'package.json') +}; + +const getRuleByLoader = (rules, loader) => rules.find(rule => rule.loader === loader); + +describe('WebpackConfigBuilder workspace packages', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('merges workspace package manifests recursively into the generated config', () => { + jest.spyOn(Module, '_resolveFilename').mockImplementation((request, parent, isMain, options) => { + if (request in fixturePackageJsonPaths) { + return fixturePackageJsonPaths[request]; + } + + return originalResolveFilename.call(Module, request, parent, isMain, options); + }); + + const rootPath = path.join(__dirname, 'fixtures', 'workspace-consumer'); + + const config = new WebpackConfigBuilder({ + rootPath, + libraryName: 'WorkspaceFixture', + entry: './src/index.js', + workspacePackages: ['test-workspace-alpha'], + alias: { + '@workspace-shared': './local-alias' + }, + snapshot: { + managedPaths: ['./local-managed'] + } + }).get(); + + const alphaRoot = path.join(fixtureDir, 'alpha'); + const betaRoot = path.join(fixtureDir, 'beta'); + const gammaRoot = path.join(fixtureDir, 'gamma'); + + expect(config.resolve.extensions).toEqual(['.ts', '.js', '.tsx', '.jsx']); + expect(config.resolve.alias).toEqual(expect.objectContaining({ + 'test-workspace-alpha': path.join(alphaRoot, 'src'), + 'test-workspace-alpha$': path.join(alphaRoot, 'src', 'index.js'), + 'test-workspace-beta': path.join(betaRoot, 'src'), + 'test-workspace-beta$': path.join(betaRoot, 'src', 'beta-entry.js'), + 'test-workspace-gamma': path.join(gammaRoot, 'src'), + 'test-workspace-gamma$': path.join(gammaRoot, 'src', 'gamma-entry.js'), + '@beta-only': path.join(betaRoot, 'beta-alias'), + '@gamma-only': path.join(gammaRoot, 'gamma-alias'), + '@workspace-shared': path.join(rootPath, 'local-alias') + })); + + const tsRule = getRuleByLoader(config.module.rules, 'ts-loader'); + expect(tsRule.include).toEqual(expect.arrayContaining([ + path.join(rootPath, 'src'), + path.join(alphaRoot, 'src'), + path.join(alphaRoot, 'alpha-extra'), + path.join(betaRoot, 'src'), + path.join(betaRoot, 'beta-extra'), + path.join(gammaRoot, 'src') + ])); + + const cssRule = config.module.rules.find(rule => Array.isArray(rule.use)); + expect(cssRule.include).toEqual(expect.arrayContaining([ + path.join(alphaRoot, 'src'), + path.join(alphaRoot, 'alpha-extra'), + path.join(gammaRoot, 'src') + ])); + expect(cssRule.include).not.toContain(path.join(rootPath, 'src')); + expect(cssRule.include).not.toContain(path.join(betaRoot, 'src')); + + const appendedRules = config.module.rules.filter(rule => Array.isArray(rule.rules)); + expect(appendedRules).toHaveLength(3); + expect(appendedRules[0]).toMatchObject({ + include: path.join(alphaRoot, 'src') + }); + expect(appendedRules[0].rules[0]).toMatchObject({ + test: /\.alpha$/, + include: path.join(alphaRoot, 'alpha-include') + }); + expect(appendedRules[0].rules[0].oneOf[0]).toMatchObject({ + test: /\.alpha-inner$/, + include: path.join(alphaRoot, 'alpha-inner') + }); + + expect(appendedRules[1]).toMatchObject({ + include: path.join(betaRoot, 'src') + }); + expect(appendedRules[1].rules[0]).toMatchObject({ + test: /\.beta$/, + exclude: path.join(betaRoot, 'beta-exclude') + }); + + expect(appendedRules[2]).toMatchObject({ + include: path.join(gammaRoot, 'src') + }); + expect(appendedRules[2].rules[0]).toMatchObject({ + test: /\.gamma$/, + include: path.join(gammaRoot, 'gamma-include') + }); + + expect(config.snapshot).toMatchObject({ + immutablePaths: expect.arrayContaining([ + path.join(alphaRoot, 'alpha-immutable'), + path.join(betaRoot, 'beta-immutable') + ]), + managedPaths: expect.arrayContaining([ + path.join(rootPath, 'local-managed'), + path.join(betaRoot, 'beta-managed') + ]), + unmanagedPaths: expect.arrayContaining([ + path.join(alphaRoot, 'alpha-unmanaged'), + path.join(gammaRoot, 'gamma-unmanaged') + ]) + }); + }); +}); diff --git a/packages/storage/webpack.config.js b/packages/storage/webpack.config.js index 2dfa2e65..1ecdd781 100644 --- a/packages/storage/webpack.config.js +++ b/packages/storage/webpack.config.js @@ -14,7 +14,7 @@ const createConfig = overrideManifest => { // Web-compatible const webNonMin = createConfig({ - target: 'web', + target: 'browserslist', distPath: './dist/web', entry: { 'scratch-storage': './src/index.ts' From 8d16eadbd30086bef7da1813cbd9f56958105d0f Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 12 Mar 2026 12:46:13 +0800 Subject: [PATCH 10/10] :wrench: chore(l10n): deep-copy to build config Signed-off-by: SimonShiki --- packages/l10n/webpack.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/l10n/webpack.config.js b/packages/l10n/webpack.config.js index 753f7ea4..ea29c186 100644 --- a/packages/l10n/webpack.config.js +++ b/packages/l10n/webpack.config.js @@ -1,12 +1,13 @@ const manifest = require('./webpack.manifest'); const WebpackConfigBuilder = require('../infra'); -const config = new WebpackConfigBuilder(Object.assign(manifest, { +const config = new WebpackConfigBuilder({ + ...manifest, entry: { l10n: manifest.entry, supportedLocales: './src/supported-locales.js', localeData: './src/locale-data.js' } -})).get(); +}).get(); module.exports = config;