tw-plugin-webpack 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+
2
+
3
+ /**
4
+ * tw-plugin-webpack — bundle a multi-file TurboWarp / Scratch extension into a
5
+ * single unsandboxed-extension file with **webpack** or **Rspack**.
6
+ *
7
+ * Write your extension across as many ES modules as you like, `export default`
8
+ * the extension class (or an already-constructed instance) from the entry
9
+ * module, and this plugin wraps the bundle in the standard TurboWarp IIFE
10
+ * template and calls `Scratch.extensions.register()` for you:
11
+ *
12
+ * ```js
13
+ * (function (Scratch) {
14
+ * "use strict";
15
+ * // ...all of your bundled modules, inlined...
16
+ * Scratch.extensions.register(new MyExtension());
17
+ * })(Scratch);
18
+ * ```
19
+ *
20
+ * Because the entire bundle lives inside the IIFE, every bare reference to the
21
+ * `Scratch` global inside your code resolves to the local parameter — exactly
22
+ * the "personal copy of the Scratch API" the TurboWarp docs recommend.
23
+ *
24
+ * @module tw-plugin-webpack
25
+ */ const PLUGIN_NAME = 'TurboWarpExtensionPlugin';
26
+ // Assets imported by the extension are inlined as base64 `data:` URIs by
27
+ // default — a TurboWarp extension is a single file and can't reference separate
28
+ // asset files. SVG/PNG/etc. imports become the `data:` strings you hand to
29
+ // `menuIconURI` / `blockIconURI`.
30
+ const DEFAULT_ASSET_PATTERN = /\.(svg|png|jpe?g|gif|webp|avif)$/i;
31
+ /**
32
+ * Registry metadata. Each field becomes a `// Key: Value` comment line at the
33
+ * very top of the file — the header the
34
+ * [TurboWarp extensions gallery](https://github.com/TurboWarp/extensions)
35
+ * requires for submission:
36
+ *
37
+ * ```js
38
+ * // Name: Consoles
39
+ * // ID: sipcconsole
40
+ * // Description: Blocks that interact with the developer console.
41
+ * // By: -SIPC-
42
+ * // License: MIT
43
+ * ```
44
+ *
45
+ * @typedef {object} TurboWarpExtensionMetadata
46
+ * @property {string} [name] Display name shown in the extension list.
47
+ * @property {string} [id] Unique extension id. **Must match** the `id` your
48
+ * `getInfo()` returns.
49
+ * @property {string} [description] One-line description for the gallery.
50
+ * @property {string | string[]} [by] Author(s). Each entry becomes its own
51
+ * `// By:` line and may include a profile link, e.g.
52
+ * `"GarboMuffin <https://scratch.mit.edu/users/GarboMuffin/>"`.
53
+ * @property {string | string[]} [original] Original author(s) when this is a
54
+ * derivative — one `// Original:` line each.
55
+ * @property {string} [license] SPDX license id, e.g. `"MPL-2.0"`.
56
+ * @property {string} [context] Extra `// Context:` line.
57
+ */ /**
58
+ * @typedef {object} TurboWarpExtensionPluginOptions
59
+ * @property {boolean} [register=true] Append a
60
+ * `Scratch.extensions.register(...)` call for the entry's chosen export. Set
61
+ * to `false` if you would rather call `register()` yourself somewhere in your
62
+ * own code (it still runs inside the IIFE, so the `Scratch` global is
63
+ * available there too).
64
+ * @property {boolean} [unsandboxed=false] Emit a guard at the top of the bundle
65
+ * that throws unless the extension is running unsandboxed
66
+ * (`Scratch.extensions.unsandboxed`). Use this for extensions that require
67
+ * direct access to the VM.
68
+ * @property {TurboWarpExtensionMetadata} [metadata] Registry metadata injected
69
+ * as the `// Name:` / `// ID:` / … comment header the TurboWarp gallery reads.
70
+ * Omit it for extensions you only ever load manually.
71
+ * @property {boolean | RegExp} [inlineAssets=true] Configure the bundler so
72
+ * importing an asset inlines it as a base64 `data:` URI — the form TurboWarp
73
+ * wants for `menuIconURI` / `blockIconURI`. With this on you can
74
+ * `import iconURI from './icon.svg'` and use `iconURI` directly. Defaults to
75
+ * matching `svg`, `png`, `jpg`, `gif`, `webp`, `avif`; pass a `RegExp` to use
76
+ * your own test, or `false` to leave asset handling to your own config.
77
+ * @property {string} [name] Human-readable name used in the unsandboxed-guard
78
+ * error message. Defaults to `metadata.name` when set, otherwise
79
+ * `"This extension"`.
80
+ * @property {string} [varName="__turbowarpExtension__"] Identifier the bundle's
81
+ * export is assigned to before registration. Only change it if it somehow
82
+ * collides with a global your extension relies on.
83
+ * @property {string | string[]} [libraryExport="default"] Which export of the
84
+ * entry module is the extension. Defaults to the default export; pass a named
85
+ * export (or a path like `['nested', 'Extension']`) to use something else.
86
+ */ /**
87
+ * Webpack / Rspack plugin that turns a normal multi-module bundle into a
88
+ * single-file TurboWarp unsandboxed extension.
89
+ *
90
+ * Structurally compatible with both `webpack.WebpackPluginInstance` and
91
+ * `@rspack/core`'s `RspackPluginInstance`. The `compiler` parameter is typed
92
+ * loosely (`any`) on purpose so the published types don't force a dependency on
93
+ * either bundler — pick whichever one you build with.
94
+ */ class TurboWarpExtensionPlugin {
95
+ /** @param {TurboWarpExtensionPluginOptions} [options] */ constructor(options = {}){
96
+ /** @type {Required<TurboWarpExtensionPluginOptions>} */ this.options = {
97
+ register: true,
98
+ unsandboxed: false,
99
+ metadata: null,
100
+ inlineAssets: true,
101
+ name: undefined,
102
+ varName: '__turbowarpExtension__',
103
+ libraryExport: 'default',
104
+ ...options
105
+ };
106
+ // Fall back to the metadata name so the guard message reads naturally
107
+ // without having to repeat the name in two places.
108
+ this.options.name = this.options.name ?? this.options.metadata?.name ?? 'This extension';
109
+ }
110
+ /** @param {any} compiler A webpack or Rspack `Compiler`. */ apply(compiler) {
111
+ // `compiler.webpack` is the bundler's own API namespace. It exists on both
112
+ // webpack 5 and Rspack, so the plugin never has to import (or even depend
113
+ // on) either one directly.
114
+ const webpack = /** @type {any} */ compiler.webpack;
115
+ const { Compilation, sources, library } = webpack;
116
+ const { ConcatSource } = sources;
117
+ const output = compiler.options.output;
118
+ // Shape the bundle so the entry's export is reachable: a single
119
+ // self-executing file that assigns the chosen export to a local `var`,
120
+ // which the IIFE wrapper below reads and registers.
121
+ output.iife = true;
122
+ output.library = {
123
+ type: 'var',
124
+ name: this.options.varName,
125
+ export: this.options.libraryExport
126
+ };
127
+ // `EnableLibraryPlugin` is auto-applied only for library types declared in
128
+ // the *initial* config. We set the type programmatically here, so we have
129
+ // to enable support for it ourselves.
130
+ new library.EnableLibraryPlugin('var').apply(compiler);
131
+ // Inline asset imports as base64 `data:` URIs so `import icon from
132
+ // './icon.svg'` yields a string usable as `menuIconURI` — and so nothing is
133
+ // emitted as a separate file (the extension must be self-contained).
134
+ if (this.options.inlineAssets) {
135
+ const test = this.options.inlineAssets instanceof RegExp ? this.options.inlineAssets : DEFAULT_ASSET_PATTERN;
136
+ const module = compiler.options.module || (compiler.options.module = {});
137
+ const rules = module.rules || (module.rules = []);
138
+ rules.push({
139
+ test,
140
+ type: 'asset/inline'
141
+ });
142
+ }
143
+ const prefix = buildPrefix(this.options);
144
+ const suffix = buildSuffix(this.options);
145
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation)=>{
146
+ compilation.hooks.processAssets.tap({
147
+ name: PLUGIN_NAME,
148
+ // Run after minification (OPTIMIZE_SIZE) but before hashing
149
+ // (OPTIMIZE_HASH) so [contenthash] filenames stay correct.
150
+ stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE
151
+ }, ()=>{
152
+ for (const file of entryJsFiles(compilation)){
153
+ compilation.updateAsset(file, (old)=>new ConcatSource(prefix, '\n', old, '\n', suffix));
154
+ }
155
+ });
156
+ });
157
+ }
158
+ }
159
+ /**
160
+ * The registry metadata header (`// Name:` / `// ID:` / …), in the order the
161
+ * gallery conventionally lists them, followed by the opening of the TurboWarp
162
+ * IIFE template and an optional unsandboxed guard.
163
+ *
164
+ * @param {Required<TurboWarpExtensionPluginOptions>} options
165
+ * @returns {string}
166
+ */ function buildPrefix(options) {
167
+ const lines = [];
168
+ const header = buildMetadataHeader(options.metadata);
169
+ if (header) lines.push(header, ''); // blank line between header and code
170
+ lines.push('(function (Scratch) {', '"use strict";');
171
+ if (options.unsandboxed) {
172
+ const message = JSON.stringify(`${options.name} must be run unsandboxed.`);
173
+ lines.push(`if (!Scratch.extensions.unsandboxed) { throw new Error(${message}); }`);
174
+ }
175
+ return lines.join('\n');
176
+ }
177
+ // Known metadata fields, paired with the exact label the gallery expects, in
178
+ // conventional order. Unlisted fields are emitted afterwards using their key
179
+ // verbatim as the label.
180
+ const METADATA_FIELDS = [
181
+ [
182
+ 'name',
183
+ 'Name'
184
+ ],
185
+ [
186
+ 'id',
187
+ 'ID'
188
+ ],
189
+ [
190
+ 'description',
191
+ 'Description'
192
+ ],
193
+ [
194
+ 'by',
195
+ 'By'
196
+ ],
197
+ [
198
+ 'original',
199
+ 'Original'
200
+ ],
201
+ [
202
+ 'license',
203
+ 'License'
204
+ ],
205
+ [
206
+ 'context',
207
+ 'Context'
208
+ ]
209
+ ];
210
+ /**
211
+ * Render registry metadata as a block of `// Key: Value` comment lines.
212
+ * Array-valued fields (e.g. `by`) produce one line per entry, and every value
213
+ * is collapsed onto a single line so it can't break out of the comment.
214
+ *
215
+ * @param {TurboWarpExtensionMetadata | null | undefined} metadata
216
+ * @returns {string} The joined comment lines, or `''` when there's nothing.
217
+ */ function buildMetadataHeader(metadata) {
218
+ if (!metadata) return '';
219
+ const lines = [];
220
+ const emit = (label, value)=>{
221
+ if (value == null) return;
222
+ for (const entry of Array.isArray(value) ? value : [
223
+ value
224
+ ]){
225
+ const text = String(entry).replace(/[\r\n]+/g, ' ').trim();
226
+ if (text) lines.push(`// ${label}: ${text}`);
227
+ }
228
+ };
229
+ const known = new Set();
230
+ for (const [key, label] of METADATA_FIELDS){
231
+ known.add(key);
232
+ if (key in metadata) emit(label, metadata[key]);
233
+ }
234
+ for (const key of Object.keys(metadata)){
235
+ if (!known.has(key)) emit(key, metadata[key]);
236
+ }
237
+ return lines.join('\n');
238
+ }
239
+ /**
240
+ * Closing of the IIFE template. When `register` is enabled, the entry's export
241
+ * is registered — instantiated first if it is a class (a function), or passed
242
+ * straight through if it is already an instance.
243
+ *
244
+ * @param {Required<TurboWarpExtensionPluginOptions>} options
245
+ * @returns {string}
246
+ */ function buildSuffix(options) {
247
+ const lines = [];
248
+ if (options.register) {
249
+ lines.push('(function () {', ` var extension = ${options.varName};`, ' Scratch.extensions.register(', ' typeof extension === "function" ? new extension() : extension', ' );', '})();');
250
+ }
251
+ lines.push('})(Scratch);');
252
+ return lines.join('\n');
253
+ }
254
+ /**
255
+ * Collect the `.js` files that make up the initial (synchronously loaded)
256
+ * entrypoints — those are what end up inside the single extension file.
257
+ *
258
+ * @param {any} compilation A webpack or Rspack `Compilation`.
259
+ * @returns {Set<string>}
260
+ */ function entryJsFiles(compilation) {
261
+ const files = new Set();
262
+ for (const entrypoint of compilation.entrypoints.values()){
263
+ for (const file of entrypoint.getFiles()){
264
+ if (file.endsWith('.js')) files.add(file);
265
+ }
266
+ }
267
+ // Fallback for unusual setups that don't surface entrypoint files: wrap
268
+ // every emitted .js asset instead.
269
+ if (files.size === 0) {
270
+ for (const name of Object.keys(compilation.assets)){
271
+ if (name.endsWith('.js')) files.add(name);
272
+ }
273
+ }
274
+ return files;
275
+ }
276
+
277
+ /* export default */ const src = (TurboWarpExtensionPlugin);
278
+
279
+ export { TurboWarpExtensionPlugin };
280
+ export default src;
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "tw-plugin-webpack",
3
+ "description": "Bundle a multi-file TurboWarp/Scratch extension into a single unsandboxed-extension file with webpack or Rspack.",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "license": "MPL-2.0",
7
+ "author": "playforge-coding",
8
+ "homepage": "https://github.com/playforge-coding/scratch4js/tree/main/packages/tw-plugin-webpack#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/playforge-coding/scratch4js.git",
12
+ "directory": "packages/tw-plugin-webpack"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/playforge-coding/scratch4js/issues"
16
+ },
17
+ "keywords": [
18
+ "turbowarp",
19
+ "scratch",
20
+ "extension",
21
+ "webpack",
22
+ "webpack-plugin",
23
+ "rspack",
24
+ "rspack-plugin",
25
+ "bundler"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "import": {
30
+ "types": "./dist/esm/index.d.ts",
31
+ "default": "./dist/esm/index.js"
32
+ },
33
+ "require": {
34
+ "types": "./dist/esm/index.d.ts",
35
+ "default": "./dist/cjs/index.cjs"
36
+ }
37
+ }
38
+ },
39
+ "main": "./dist/cjs/index.cjs",
40
+ "module": "./dist/esm/index.js",
41
+ "types": "./dist/esm/index.d.ts",
42
+ "files": [
43
+ "dist"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "peerDependencies": {
52
+ "@rspack/core": "*",
53
+ "webpack": "^5.1.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@rspack/core": {
57
+ "optional": true
58
+ },
59
+ "webpack": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@rsbuild/core": "^1.3.22",
65
+ "@rslib/core": "^0.22.0",
66
+ "@rspack/core": "^2.0.8",
67
+ "typescript": "^5.9.0"
68
+ },
69
+ "scripts": {
70
+ "build": "rslib build",
71
+ "dev": "rslib build --watch",
72
+ "build:example": "node examples/multi-file-extension/build.mjs"
73
+ }
74
+ }