Skip to content

Commit ec297b3

Browse files
committed
feat: basic support for ESM fiddles
1 parent 1e40d19 commit ec297b3

17 files changed

+187
-63
lines changed

src/interfaces.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,15 @@ export const enum GenericDialogType {
137137
'success' = 'success',
138138
}
139139

140-
export type EditorId = `${string}.${'js' | 'html' | 'css'}`;
140+
export type EditorId = `${string}.${'cjs' | 'js' | 'mjs' | 'html' | 'css'}`;
141141

142142
export type EditorValues = Record<EditorId, string>;
143143

144-
// main.js gets special treatment: it is required as the entry point
144+
// main.{cjs,js,mjs} gets special treatment: it is required as the entry point
145145
// when we run fiddles or create a package.json to package fiddles.
146+
export const MAIN_CJS = 'main.cjs';
146147
export const MAIN_JS = 'main.js';
148+
export const MAIN_MJS = 'main.mjs';
147149

148150
export const PACKAGE_NAME = 'package.json';
149151

src/renderer/components/sidebar-file-tree.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { observer } from 'mobx-react';
1717
import { EditorId } from '../../interfaces';
1818
import { EditorPresence } from '../editor-mosaic';
1919
import { AppState } from '../state';
20-
import { isRequiredFile, isSupportedFile } from '../utils/editor-utils';
20+
import { isMainEntryPoint, isSupportedFile } from '../utils/editor-utils';
2121

2222
interface FileTreeProps {
2323
appState: AppState;
@@ -68,7 +68,7 @@ export const SidebarFileTree = observer(
6868
onClick={() => this.renameEditor(editorId)}
6969
/>
7070
<MenuItem
71-
disabled={isRequiredFile(editorId)}
71+
disabled={isMainEntryPoint(editorId)}
7272
icon="remove"
7373
text="Delete"
7474
intent="danger"
@@ -191,7 +191,7 @@ export const SidebarFileTree = observer(
191191

192192
if (!isSupportedFile(id)) {
193193
await appState.showErrorDialog(
194-
`Invalid filename "${id}": Must be a file ending in .js, .html, or .css`,
194+
`Invalid filename "${id}": Must be a file ending in .cjs, .js, .mjs, .html, or .css`,
195195
);
196196
return;
197197
}

src/renderer/editor-mosaic.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MosaicDirection, MosaicNode, getLeaves } from 'react-mosaic-component';
55
import {
66
compareEditors,
77
getEmptyContent,
8+
isMainEntryPoint,
89
isSupportedFile,
910
monacoLanguage,
1011
} from './utils/editor-utils';
@@ -140,8 +141,11 @@ export class EditorMosaic {
140141

141142
/** Add a file. If we already have a file with that name, replace it. */
142143
private addFile(id: EditorId, value: string) {
143-
if (!isSupportedFile(id))
144-
throw new Error(`Cannot add file "${id}": Must be .js, .html, or .css`);
144+
if (!isSupportedFile(id)) {
145+
throw new Error(
146+
`Cannot add file "${id}": Must be .cjs, .js, .mjs, .html, or .css`,
147+
);
148+
}
145149

146150
// create a monaco model with the file's contents
147151
const { monaco } = window;
@@ -259,8 +263,17 @@ export class EditorMosaic {
259263

260264
/** Add a new file to the mosaic */
261265
public addNewFile(id: EditorId, value: string = getEmptyContent(id)) {
262-
if (this.files.has(id))
266+
if (this.files.has(id)) {
263267
throw new Error(`Cannot add file "${id}": File already exists`);
268+
}
269+
270+
const entryPoint = this.mainEntryPointFile();
271+
272+
if (isMainEntryPoint(id) && entryPoint) {
273+
throw new Error(
274+
`Cannot add file "${id}": Main entry point ${entryPoint} exists`,
275+
);
276+
}
264277

265278
this.addFile(id, value);
266279
}
@@ -302,6 +315,10 @@ export class EditorMosaic {
302315
for (const editor of this.editors.values()) editor.updateOptions(options);
303316
}
304317

318+
public mainEntryPointFile(): EditorId | undefined {
319+
return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id));
320+
}
321+
305322
//=== Listen for user edits
306323

307324
private ignoreAllEdits() {

src/renderer/remote-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export class RemoteLoader {
188188
// contain any supported files. Throw an error to let the user know.
189189
if (Object.keys(values).length === 0) {
190190
throw new Error(
191-
'This Gist did not contain any supported files. Supported files must have one of the following extensions: .js, .css, or .html.',
191+
'This Gist did not contain any supported files. Supported files must have one of the following extensions: .cjs, .js, .mjs, .css, or .html.',
192192
);
193193
}
194194

src/renderer/runner.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import semver from 'semver';
2+
13
import { Bisector } from './bisect';
24
import { AppState } from './state';
35
import { maybePlural } from './utils/plural-maybe';
46
import {
57
FileTransformOperation,
68
InstallState,
9+
MAIN_MJS,
710
PMOperationOptions,
811
PackageJsonOptions,
912
RunResult,
@@ -163,6 +166,20 @@ export class Runner {
163166
return RunResult.INVALID;
164167
}
165168

169+
if (
170+
semver.lt(ver.version, '28.0.0') &&
171+
!ver.version.startsWith('28.0.0-nightly')
172+
) {
173+
const entryPoint = appState.editorMosaic.mainEntryPointFile();
174+
175+
if (entryPoint === MAIN_MJS) {
176+
appState.showErrorDialog(
177+
'ESM main entry points are only supported starting in Electron 28',
178+
);
179+
return RunResult.INVALID;
180+
}
181+
}
182+
166183
if (appState.isClearingConsoleOnRun) {
167184
appState.clearConsole();
168185
}

src/renderer/utils/editor-utils.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,65 @@
1-
import { EditorId, MAIN_JS } from '../../interfaces';
1+
import { EditorId, MAIN_CJS, MAIN_JS, MAIN_MJS } from '../../interfaces';
22
import {
33
ensureRequiredFiles,
44
getEmptyContent,
55
getSuffix,
6-
isRequiredFile,
6+
isMainEntryPoint,
77
isSupportedFile,
88
} from '../../utils/editor-utils';
99

1010
export {
1111
ensureRequiredFiles,
1212
getEmptyContent,
1313
getSuffix,
14-
isRequiredFile,
14+
isMainEntryPoint,
1515
isSupportedFile,
1616
};
1717

1818
// The order of these fields is the order that
1919
// they'll be sorted in the mosaic
2020
const KNOWN_FILES: string[] = [
21+
MAIN_CJS,
2122
MAIN_JS,
23+
MAIN_MJS,
24+
'renderer.cjs',
2225
'renderer.js',
26+
'renderer.mjs',
2327
'index.html',
28+
'preload.cjs',
2429
'preload.js',
30+
'preload.mjs',
2531
'styles.css',
2632
];
2733

2834
export function isKnownFile(filename: string): boolean {
2935
return KNOWN_FILES.includes(filename);
3036
}
3137

32-
const TITLE_MAP = new Map<EditorId, string>([
33-
[MAIN_JS, `Main Process (${MAIN_JS})`],
34-
['renderer.js', 'Renderer Process (renderer.js)'],
35-
['index.html', 'HTML (index.html)'],
36-
['preload.js', 'Preload (preload.js)'],
37-
['styles.css', 'Stylesheet (styles.css)'],
38-
]);
39-
4038
export function getEditorTitle(id: EditorId): string {
41-
return TITLE_MAP.get(id) || id;
39+
switch (id) {
40+
case 'index.html':
41+
return 'HTML (index.html)';
42+
43+
case MAIN_CJS:
44+
case MAIN_JS:
45+
case MAIN_MJS:
46+
return `Main Process (${id})`;
47+
48+
case 'preload.cjs':
49+
case 'preload.js':
50+
case 'preload.mjs':
51+
return `Preload (${id})`;
52+
53+
case 'renderer.cjs':
54+
case 'renderer.js':
55+
case 'renderer.mjs':
56+
return `Renderer Process (${id})`;
57+
58+
case 'styles.css':
59+
return 'Stylesheet (styles.css)';
60+
}
61+
62+
return id;
4263
}
4364

4465
// the KNOWN_FILES, in the order of that array, go first.

src/renderer/utils/get-package.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ export async function getPackageJson(
4242
}
4343
}
4444

45+
const entryPoint = appState.editorMosaic.mainEntryPointFile() ?? MAIN_JS;
46+
4547
return JSON.stringify(
4648
{
4749
name,
4850
productName: name,
4951
description: 'My Electron application description',
5052
keywords: [],
51-
main: `./${MAIN_JS}`,
53+
main: `./${entryPoint}`,
5254
version: '1.0.0',
5355
author: appState.packageAuthor,
5456
scripts: {

src/utils/editor-utils.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
1-
import { EditorId, EditorValues, MAIN_JS } from '../interfaces';
1+
import {
2+
EditorId,
3+
EditorValues,
4+
MAIN_CJS,
5+
MAIN_JS,
6+
MAIN_MJS,
7+
} from '../interfaces';
28

3-
const requiredFiles = new Set<EditorId>([MAIN_JS]);
9+
const mainEntryPointFiles = new Set<EditorId>([MAIN_CJS, MAIN_JS, MAIN_MJS]);
410

5-
const EMPTY_EDITOR_CONTENT = {
6-
css: '/* Empty */',
7-
html: '<!-- Empty -->',
8-
js: '// Empty',
11+
const EMPTY_EDITOR_CONTENT: Record<EditorId, string> = {
12+
'.css': '/* Empty */',
13+
'.html': '<!-- Empty -->',
14+
'.cjs': '// Empty',
15+
'.js': '// Empty',
16+
'.mjs': '// Empty',
917
} as const;
1018

1119
export function getEmptyContent(filename: string): string {
12-
return (
13-
EMPTY_EDITOR_CONTENT[
14-
getSuffix(filename) as keyof typeof EMPTY_EDITOR_CONTENT
15-
] || ''
16-
);
20+
return EMPTY_EDITOR_CONTENT[`.${getSuffix(filename)}` as EditorId] || '';
1721
}
1822

19-
export function isRequiredFile(id: EditorId) {
20-
return requiredFiles.has(id);
23+
export function isMainEntryPoint(id: EditorId) {
24+
return mainEntryPointFiles.has(id);
2125
}
2226

2327
export function ensureRequiredFiles(values: EditorValues): EditorValues {
24-
for (const file of requiredFiles) {
25-
values[file] ||= getEmptyContent(file);
28+
const mainEntryPoint = Object.keys(values).find((id: EditorId) =>
29+
mainEntryPointFiles.has(id),
30+
) as EditorId | undefined;
31+
32+
// If no entry point is found, default to main.js
33+
if (!mainEntryPoint) {
34+
values[MAIN_JS] = getEmptyContent(MAIN_JS);
35+
} else {
36+
values[mainEntryPoint] ||= getEmptyContent(mainEntryPoint);
2637
}
38+
2739
return values;
2840
}
2941

@@ -32,5 +44,5 @@ export function getSuffix(filename: string) {
3244
}
3345

3446
export function isSupportedFile(filename: string): filename is EditorId {
35-
return /\.(css|html|js)$/i.test(filename);
47+
return /\.(css|html|cjs|js|mjs)$/i.test(filename);
3648
}

tests/main/menu-spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import * as electron from 'electron';
66
import { mocked } from 'jest-mock';
77

8-
import { BlockableAccelerator } from '../../src/interfaces';
8+
import { BlockableAccelerator, MAIN_JS } from '../../src/interfaces';
99
import { IpcEvents } from '../../src/ipc-events';
1010
import {
1111
saveFiddle,
@@ -271,7 +271,7 @@ describe('menu', () => {
271271
});
272272

273273
it('attempts to open a template on click', async () => {
274-
const editorValues = { 'main.js': 'foobar' };
274+
const editorValues = { [MAIN_JS]: 'foobar' };
275275
mocked(getTemplateValues).mockResolvedValue(editorValues);
276276
await showMe.submenu[0].submenu[0].click();
277277
expect(ipcMainManager.send).toHaveBeenCalledWith(

tests/main/utils/read-fiddle-spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import * as path from 'node:path';
33
import * as fs from 'fs-extra';
44
import { mocked } from 'jest-mock';
55

6-
import { EditorId, EditorValues, MAIN_JS } from '../../../src/interfaces';
6+
import {
7+
EditorId,
8+
EditorValues,
9+
MAIN_CJS,
10+
MAIN_JS,
11+
MAIN_MJS,
12+
} from '../../../src/interfaces';
713
import { readFiddle } from '../../../src/main/utils/read-fiddle';
814
import {
915
ensureRequiredFiles,
@@ -41,6 +47,22 @@ describe('read-fiddle', () => {
4147
expect(fiddle).toStrictEqual({ [MAIN_JS]: getEmptyContent(MAIN_JS) });
4248
});
4349

50+
it('does not inject main.js if main.cjs or main.mjs present', async () => {
51+
for (const entryPoint of [MAIN_CJS, MAIN_MJS]) {
52+
const mockValues = {
53+
[entryPoint]: getEmptyContent(entryPoint),
54+
};
55+
setupFSMocks(mockValues);
56+
57+
const fiddle = await readFiddle(folder);
58+
59+
expect(console.warn).not.toHaveBeenCalled();
60+
expect(fiddle).toStrictEqual({
61+
[entryPoint]: getEmptyContent(entryPoint),
62+
});
63+
}
64+
});
65+
4466
it('reads supported files', async () => {
4567
const content = 'hello';
4668
const mockValues = { [MAIN_JS]: content };

0 commit comments

Comments
 (0)