Skip to content

Commit 5622127

Browse files
authored
feat: basic support for ESM fiddles (#1474)
* feat: basic support for ESM fiddles * chore: fix lint
1 parent ca0291c commit 5622127

17 files changed

+185
-62
lines changed

src/interfaces.ts

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

140-
export type EditorId = `${string}.${'js' | 'html' | 'css' | 'json'}`;
140+
export type EditorId = `${string}.${
141+
| 'cjs'
142+
| 'js'
143+
| 'mjs'
144+
| 'html'
145+
| 'css'
146+
| 'json'}`;
141147

142148
export type EditorValues = Record<EditorId, string>;
143149

144-
// main.js gets special treatment: it is required as the entry point
150+
// main.{cjs,js,mjs} gets special treatment: it is required as the entry point
145151
// when we run fiddles or create a package.json to package fiddles.
152+
export const MAIN_CJS = 'main.cjs';
146153
export const MAIN_JS = 'main.js';
154+
export const MAIN_MJS = 'main.mjs';
147155

148156
export const PACKAGE_NAME = 'package.json';
149157

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, PACKAGE_NAME } 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"
@@ -201,7 +201,7 @@ export const SidebarFileTree = observer(
201201

202202
if (!isSupportedFile(id)) {
203203
await appState.showErrorDialog(
204-
`Invalid filename "${id}": Must be a file ending in .js, .html, .css, or .json`,
204+
`Invalid filename "${id}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`,
205205
);
206206
return;
207207
}

src/renderer/editor-mosaic.ts

Lines changed: 16 additions & 2 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';
@@ -151,7 +152,7 @@ export class EditorMosaic {
151152

152153
if (!isSupportedFile(id)) {
153154
throw new Error(
154-
`Cannot add file "${id}": Must be .js, .html, .css, or .json`,
155+
`Cannot add file "${id}": Must be .cjs, .js, .mjs, .html, .css, or .json`,
155156
);
156157
}
157158

@@ -275,8 +276,17 @@ export class EditorMosaic {
275276

276277
/** Add a new file to the mosaic */
277278
public addNewFile(id: EditorId, value: string = getEmptyContent(id)) {
278-
if (this.files.has(id))
279+
if (this.files.has(id)) {
279280
throw new Error(`Cannot add file "${id}": File already exists`);
281+
}
282+
283+
const entryPoint = this.mainEntryPointFile();
284+
285+
if (isMainEntryPoint(id) && entryPoint) {
286+
throw new Error(
287+
`Cannot add file "${id}": Main entry point ${entryPoint} exists`,
288+
);
289+
}
280290

281291
this.addFile(id, value);
282292
}
@@ -318,6 +328,10 @@ export class EditorMosaic {
318328
for (const editor of this.editors.values()) editor.updateOptions(options);
319329
}
320330

331+
public mainEntryPointFile(): EditorId | undefined {
332+
return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id));
333+
}
334+
321335
//=== Listen for user edits
322336

323337
private ignoreAllEdits() {

src/renderer/remote-loader.ts

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

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,
@@ -165,6 +168,20 @@ export class Runner {
165168
return RunResult.INVALID;
166169
}
167170

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

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: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,42 @@
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',
9-
json: '{}',
11+
const EMPTY_EDITOR_CONTENT: Record<EditorId, string> = {
12+
'.css': '/* Empty */',
13+
'.html': '<!-- Empty -->',
14+
'.cjs': '// Empty',
15+
'.js': '// Empty',
16+
'.mjs': '// Empty',
17+
'.json': '{}',
1018
} as const;
1119

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

20-
export function isRequiredFile(id: EditorId) {
21-
return requiredFiles.has(id);
24+
export function isMainEntryPoint(id: EditorId) {
25+
return mainEntryPointFiles.has(id);
2226
}
2327

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

@@ -33,5 +45,5 @@ export function getSuffix(filename: string) {
3345
}
3446

3547
export function isSupportedFile(filename: string): filename is EditorId {
36-
return /\.(css|html|js|json)$/i.test(filename);
48+
return /\.(css|html|cjs|js|mjs|json)$/i.test(filename);
3749
}

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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { mocked } from 'jest-mock';
66
import {
77
EditorId,
88
EditorValues,
9+
MAIN_CJS,
910
MAIN_JS,
11+
MAIN_MJS,
1012
PACKAGE_NAME,
1113
} from '../../../src/interfaces';
1214
import { readFiddle } from '../../../src/main/utils/read-fiddle';
@@ -46,6 +48,22 @@ describe('read-fiddle', () => {
4648
expect(fiddle).toStrictEqual({ [MAIN_JS]: getEmptyContent(MAIN_JS) });
4749
});
4850

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

tests/renderer/app-spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('App component', () => {
101101
mocked(openFiddle).mockResolvedValueOnce(undefined);
102102

103103
const filePath = '/fake/path';
104-
const files = { MAIN_JS: 'foo' };
104+
const files = { [MAIN_JS]: 'foo' };
105105
await app.openFiddle({ localFiddle: { filePath, files } });
106106
expect(openFiddle).toHaveBeenCalledWith(filePath, files);
107107
});

tests/renderer/editor-mosaic-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ describe('EditorMosaic', () => {
392392
direction: 'row',
393393
first: {
394394
direction: 'column',
395-
first: 'main.js',
395+
first: MAIN_JS,
396396
second: 'renderer.js',
397397
},
398398
second: {

0 commit comments

Comments
 (0)