tw-plugin-rollup 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,303 @@
1
+ import { readFileSync } from "node:fs";
2
+ import magic_string from "magic-string";
3
+
4
+
5
+
6
+
7
+
8
+ /**
9
+ * tw-plugin-rollup — bundle a multi-file TurboWarp / Scratch extension into a
10
+ * single unsandboxed-extension file with **Rollup**, **Rolldown**, or **Vite**
11
+ * (including Vite 8, which builds on Rolldown).
12
+ *
13
+ * Write your extension across as many ES modules as you like, `export default`
14
+ * the extension class (or an already-constructed instance) from the entry
15
+ * module, and this plugin wraps the bundle in the standard TurboWarp IIFE
16
+ * template and calls `Scratch.extensions.register()` for you:
17
+ *
18
+ * ```js
19
+ * (function (Scratch) {
20
+ * "use strict";
21
+ * // ...all of your bundled modules, inlined...
22
+ * Scratch.extensions.register(new MyExtension());
23
+ * })(Scratch);
24
+ * ```
25
+ *
26
+ * Because the entire bundle lives inside the IIFE, every bare reference to the
27
+ * `Scratch` global inside your code resolves to the local parameter — exactly
28
+ * the "personal copy of the Scratch API" the TurboWarp docs recommend.
29
+ *
30
+ * The plugin uses only the standard Rollup plugin API plus Node's `fs`, so the
31
+ * exact same object works in Rollup, Rolldown, and Vite without depending on
32
+ * any one of them.
33
+ *
34
+ * @module tw-plugin-rollup
35
+ */
36
+
37
+ const PLUGIN_NAME = 'turbowarp-extension';
38
+ // Assets imported by the extension are inlined as base64 `data:` URIs by
39
+ // default — a TurboWarp extension is a single file and can't reference separate
40
+ // asset files. SVG/PNG/etc. imports become the `data:` strings you hand to
41
+ // `menuIconURI` / `blockIconURI`.
42
+ const DEFAULT_ASSET_PATTERN = /\.(svg|png|jpe?g|gif|webp|avif)$/i;
43
+ // Maps the file extensions we inline to the MIME type the `data:` URI advertises.
44
+ const MIME_TYPES = {
45
+ svg: 'image/svg+xml',
46
+ png: 'image/png',
47
+ jpg: 'image/jpeg',
48
+ jpeg: 'image/jpeg',
49
+ gif: 'image/gif',
50
+ webp: 'image/webp',
51
+ avif: 'image/avif'
52
+ };
53
+ /**
54
+ * Registry metadata. Each field becomes a `// Key: Value` comment line at the
55
+ * very top of the file — the header the
56
+ * [TurboWarp extensions gallery](https://github.com/TurboWarp/extensions)
57
+ * requires for submission:
58
+ *
59
+ * ```js
60
+ * // Name: Consoles
61
+ * // ID: sipcconsole
62
+ * // Description: Blocks that interact with the developer console.
63
+ * // By: -SIPC-
64
+ * // License: MIT
65
+ * ```
66
+ *
67
+ * @typedef {object} TurboWarpExtensionMetadata
68
+ * @property {string} [name] Display name shown in the extension list.
69
+ * @property {string} [id] Unique extension id. **Must match** the `id` your
70
+ * `getInfo()` returns.
71
+ * @property {string} [description] One-line description for the gallery.
72
+ * @property {string | string[]} [by] Author(s). Each entry becomes its own
73
+ * `// By:` line and may include a profile link, e.g.
74
+ * `"GarboMuffin <https://scratch.mit.edu/users/GarboMuffin/>"`.
75
+ * @property {string | string[]} [original] Original author(s) when this is a
76
+ * derivative — one `// Original:` line each.
77
+ * @property {string} [license] SPDX license id, e.g. `"MPL-2.0"`.
78
+ * @property {string} [context] Extra `// Context:` line.
79
+ */ /**
80
+ * @typedef {object} TurboWarpExtensionPluginOptions
81
+ * @property {boolean} [register=true] Append a
82
+ * `Scratch.extensions.register(...)` call for the entry's chosen export. Set
83
+ * to `false` if you would rather call `register()` yourself somewhere in your
84
+ * own code (it still runs inside the IIFE, so the `Scratch` global is
85
+ * available there too).
86
+ * @property {boolean} [unsandboxed=false] Emit a guard at the top of the bundle
87
+ * that throws unless the extension is running unsandboxed
88
+ * (`Scratch.extensions.unsandboxed`). Use this for extensions that require
89
+ * direct access to the VM.
90
+ * @property {TurboWarpExtensionMetadata} [metadata] Registry metadata injected
91
+ * as the `// Name:` / `// ID:` / … comment header the TurboWarp gallery reads.
92
+ * Omit it for extensions you only ever load manually.
93
+ * @property {boolean | RegExp} [inlineAssets=true] Have the plugin resolve asset
94
+ * imports to a base64 `data:` URI — the form TurboWarp wants for
95
+ * `menuIconURI` / `blockIconURI`. With this on you can
96
+ * `import iconURI from './icon.svg'` and use `iconURI` directly. Defaults to
97
+ * matching `svg`, `png`, `jpg`, `gif`, `webp`, `avif`; pass a `RegExp` to use
98
+ * your own test, or `false` to leave asset handling to your own config (e.g.
99
+ * Vite's, which already inlines assets).
100
+ * @property {string} [name] Human-readable name used in the unsandboxed-guard
101
+ * error message. Defaults to `metadata.name` when set, otherwise
102
+ * `"This extension"`.
103
+ * @property {string} [varName="__turbowarpExtension__"] Identifier the bundle's
104
+ * export is assigned to before registration. Only change it if it somehow
105
+ * collides with a global your extension relies on.
106
+ * @property {string | string[]} [libraryExport="default"] Which export of the
107
+ * entry module is the extension. Defaults to the default export; pass a named
108
+ * export (or a path like `['nested', 'Extension']`) to use something else.
109
+ */ /**
110
+ * Rollup / Rolldown / Vite plugin that turns a normal multi-module bundle into
111
+ * a single-file TurboWarp unsandboxed extension.
112
+ *
113
+ * Returns a plain Rollup plugin object, so it slots straight into a Rollup or
114
+ * Rolldown `plugins` array, or a Vite config's `plugins`. It carries an
115
+ * `enforce: 'pre'` hint so that, under Vite, its asset `load` hook runs before
116
+ * Vite's own asset handling.
117
+ *
118
+ * @param {TurboWarpExtensionPluginOptions} [options]
119
+ * @returns {import('rollup').Plugin}
120
+ */ function turbowarpExtension(options = {}) {
121
+ /** @type {Required<TurboWarpExtensionPluginOptions>} */ const resolved = {
122
+ register: true,
123
+ unsandboxed: false,
124
+ metadata: null,
125
+ inlineAssets: true,
126
+ name: undefined,
127
+ varName: '__turbowarpExtension__',
128
+ libraryExport: 'default',
129
+ ...options
130
+ };
131
+ // Fall back to the metadata name so the guard message reads naturally without
132
+ // having to repeat the name in two places.
133
+ resolved.name = resolved.name ?? resolved.metadata?.name ?? 'This extension';
134
+ const assetPattern = resolved.inlineAssets instanceof RegExp ? resolved.inlineAssets : DEFAULT_ASSET_PATTERN;
135
+ const prefix = buildPrefix(resolved);
136
+ const suffix = buildSuffix(resolved);
137
+ return {
138
+ name: PLUGIN_NAME,
139
+ // Vite-only hint (ignored by Rollup/Rolldown): run our `load` hook ahead of
140
+ // Vite's asset plugins so `import icon from './icon.svg'` reaches us first.
141
+ enforce: 'pre',
142
+ // Shape the bundle so the entry's export is reachable: a single
143
+ // self-executing IIFE that captures the chosen export in `varName`, which
144
+ // the wrapper below reads and registers.
145
+ outputOptions (outputOptions) {
146
+ outputOptions.format = 'iife';
147
+ outputOptions.name = resolved.varName;
148
+ // `default` exposes the bare default export as `varName`; any other export
149
+ // selector needs the namespace object so we can index into it (see
150
+ // `exportAccessor`).
151
+ outputOptions.exports = resolved.libraryExport === 'default' ? 'default' : 'named';
152
+ // Single pasteable file: never split, even if the graph has dynamic imports.
153
+ outputOptions.inlineDynamicImports = true;
154
+ return outputOptions;
155
+ },
156
+ // Inline asset imports as base64 `data:` URIs so `import icon from
157
+ // './icon.svg'` yields a string usable as `menuIconURI` — and so nothing is
158
+ // emitted as a separate file (the extension must be self-contained).
159
+ load (id) {
160
+ if (!resolved.inlineAssets) return null;
161
+ // Strip any query/hash suffix bundlers tack on (e.g. Vite's `?import`).
162
+ const path = id.replace(/[?#].*$/, '');
163
+ if (!assetPattern.test(path)) return null;
164
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
165
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
166
+ const base64 = readFileSync(path).toString('base64');
167
+ const dataUri = `data:${mime};base64,${base64}`;
168
+ return {
169
+ code: `export default ${JSON.stringify(dataUri)};`,
170
+ map: null
171
+ };
172
+ },
173
+ // Wrap the entry chunk in the TurboWarp IIFE template. Runs after Rollup has
174
+ // produced the inner `var varName = (function () { … })()` IIFE, so the
175
+ // export is already captured by the time the registration suffix reads it.
176
+ renderChunk (code, chunk, outputOptions) {
177
+ if (!chunk.isEntry) return null;
178
+ const magic = new magic_string(code);
179
+ magic.prepend(`${prefix}\n`);
180
+ magic.append(`\n${suffix}`);
181
+ return {
182
+ code: magic.toString(),
183
+ map: outputOptions.sourcemap ? magic.generateMap({
184
+ hires: true
185
+ }) : null
186
+ };
187
+ }
188
+ };
189
+ }
190
+ /**
191
+ * The registry metadata header (`// Name:` / `// ID:` / …), in the order the
192
+ * gallery conventionally lists them, followed by the opening of the TurboWarp
193
+ * IIFE template and an optional unsandboxed guard.
194
+ *
195
+ * @param {Required<TurboWarpExtensionPluginOptions>} options
196
+ * @returns {string}
197
+ */ function buildPrefix(options) {
198
+ const lines = [];
199
+ const header = buildMetadataHeader(options.metadata);
200
+ if (header) lines.push(header, ''); // blank line between header and code
201
+ lines.push('(function (Scratch) {', '"use strict";');
202
+ if (options.unsandboxed) {
203
+ const message = JSON.stringify(`${options.name} must be run unsandboxed.`);
204
+ lines.push(`if (!Scratch.extensions.unsandboxed) { throw new Error(${message}); }`);
205
+ }
206
+ return lines.join('\n');
207
+ }
208
+ // Known metadata fields, paired with the exact label the gallery expects, in
209
+ // conventional order. Unlisted fields are emitted afterwards using their key
210
+ // verbatim as the label.
211
+ const METADATA_FIELDS = [
212
+ [
213
+ 'name',
214
+ 'Name'
215
+ ],
216
+ [
217
+ 'id',
218
+ 'ID'
219
+ ],
220
+ [
221
+ 'description',
222
+ 'Description'
223
+ ],
224
+ [
225
+ 'by',
226
+ 'By'
227
+ ],
228
+ [
229
+ 'original',
230
+ 'Original'
231
+ ],
232
+ [
233
+ 'license',
234
+ 'License'
235
+ ],
236
+ [
237
+ 'context',
238
+ 'Context'
239
+ ]
240
+ ];
241
+ /**
242
+ * Render registry metadata as a block of `// Key: Value` comment lines.
243
+ * Array-valued fields (e.g. `by`) produce one line per entry, and every value
244
+ * is collapsed onto a single line so it can't break out of the comment.
245
+ *
246
+ * @param {TurboWarpExtensionMetadata | null | undefined} metadata
247
+ * @returns {string} The joined comment lines, or `''` when there's nothing.
248
+ */ function buildMetadataHeader(metadata) {
249
+ if (!metadata) return '';
250
+ const lines = [];
251
+ const emit = (label, value)=>{
252
+ if (value == null) return;
253
+ for (const entry of Array.isArray(value) ? value : [
254
+ value
255
+ ]){
256
+ const text = String(entry).replace(/[\r\n]+/g, ' ').trim();
257
+ if (text) lines.push(`// ${label}: ${text}`);
258
+ }
259
+ };
260
+ const known = new Set();
261
+ for (const [key, label] of METADATA_FIELDS){
262
+ known.add(key);
263
+ if (key in metadata) emit(label, metadata[key]);
264
+ }
265
+ for (const key of Object.keys(metadata)){
266
+ if (!known.has(key)) emit(key, metadata[key]);
267
+ }
268
+ return lines.join('\n');
269
+ }
270
+ /**
271
+ * Build the JS expression that reads the chosen export off `varName`. For the
272
+ * default export that's just `varName`; a named export (or a nested path)
273
+ * indexes into the namespace object Rollup assigns to `varName`.
274
+ *
275
+ * @param {Required<TurboWarpExtensionPluginOptions>} options
276
+ * @returns {string}
277
+ */ function exportAccessor(options) {
278
+ if (options.libraryExport === 'default') return options.varName;
279
+ const path = Array.isArray(options.libraryExport) ? options.libraryExport : [
280
+ options.libraryExport
281
+ ];
282
+ return path.reduce((expr, key)=>`${expr}[${JSON.stringify(key)}]`, options.varName);
283
+ }
284
+ /**
285
+ * Closing of the IIFE template. When `register` is enabled, the entry's export
286
+ * is registered — instantiated first if it is a class (a function), or passed
287
+ * straight through if it is already an instance.
288
+ *
289
+ * @param {Required<TurboWarpExtensionPluginOptions>} options
290
+ * @returns {string}
291
+ */ function buildSuffix(options) {
292
+ const lines = [];
293
+ if (options.register) {
294
+ lines.push('(function () {', ` var extension = ${exportAccessor(options)};`, ' Scratch.extensions.register(', ' typeof extension === "function" ? new extension() : extension', ' );', '})();');
295
+ }
296
+ lines.push('})(Scratch);');
297
+ return lines.join('\n');
298
+ }
299
+
300
+ /* export default */ const src = (turbowarpExtension);
301
+
302
+ export { turbowarpExtension };
303
+ export default src;
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "tw-plugin-rollup",
3
+ "description": "Bundle a multi-file TurboWarp/Scratch extension into a single unsandboxed-extension file with Rollup, Rolldown, or Vite.",
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-rollup#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/playforge-coding/scratch4js.git",
12
+ "directory": "packages/tw-plugin-rollup"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/playforge-coding/scratch4js/issues"
16
+ },
17
+ "keywords": [
18
+ "turbowarp",
19
+ "scratch",
20
+ "extension",
21
+ "rollup",
22
+ "rollup-plugin",
23
+ "rolldown",
24
+ "vite",
25
+ "vite-plugin",
26
+ "bundler"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "import": {
31
+ "types": "./dist/esm/index.d.ts",
32
+ "default": "./dist/esm/index.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/esm/index.d.ts",
36
+ "default": "./dist/cjs/index.cjs"
37
+ }
38
+ }
39
+ },
40
+ "main": "./dist/cjs/index.cjs",
41
+ "module": "./dist/esm/index.js",
42
+ "types": "./dist/esm/index.d.ts",
43
+ "files": [
44
+ "dist"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "dependencies": {
53
+ "magic-string": "^0.30.0"
54
+ },
55
+ "peerDependencies": {
56
+ "rollup": "^3.0.0 || ^4.0.0",
57
+ "rolldown": "*",
58
+ "vite": ">=4"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "rollup": {
62
+ "optional": true
63
+ },
64
+ "rolldown": {
65
+ "optional": true
66
+ },
67
+ "vite": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@rsbuild/core": "^1.3.22",
73
+ "@rslib/core": "^0.22.0",
74
+ "rollup": "^4.0.0",
75
+ "typescript": "^5.9.0"
76
+ },
77
+ "scripts": {
78
+ "build": "rslib build",
79
+ "dev": "rslib build --watch",
80
+ "build:example": "node examples/multi-file-extension/build.mjs"
81
+ }
82
+ }