diff --git a/src/interfaces.ts b/src/interfaces.ts index 7d96968817..51f73fde5a 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -137,13 +137,21 @@ export const enum GenericDialogType { 'success' = 'success', } -export type EditorId = `${string}.${'js' | 'html' | 'css' | 'json'}`; +export type EditorId = `${string}.${ + | 'cjs' + | 'js' + | 'mjs' + | 'html' + | 'css' + | 'json'}`; export type EditorValues = Record; -// main.js gets special treatment: it is required as the entry point +// main.{cjs,js,mjs} gets special treatment: it is required as the entry point // when we run fiddles or create a package.json to package fiddles. +export const MAIN_CJS = 'main.cjs'; export const MAIN_JS = 'main.js'; +export const MAIN_MJS = 'main.mjs'; export const PACKAGE_NAME = 'package.json'; diff --git a/src/renderer/components/sidebar-file-tree.tsx b/src/renderer/components/sidebar-file-tree.tsx index c63818fafd..95a9578914 100644 --- a/src/renderer/components/sidebar-file-tree.tsx +++ b/src/renderer/components/sidebar-file-tree.tsx @@ -17,7 +17,7 @@ import { observer } from 'mobx-react'; import { EditorId, PACKAGE_NAME } from '../../interfaces'; import { EditorPresence } from '../editor-mosaic'; import { AppState } from '../state'; -import { isRequiredFile, isSupportedFile } from '../utils/editor-utils'; +import { isMainEntryPoint, isSupportedFile } from '../utils/editor-utils'; interface FileTreeProps { appState: AppState; @@ -68,7 +68,7 @@ export const SidebarFileTree = observer( onClick={() => this.renameEditor(editorId)} /> isMainEntryPoint(id)); + } + //=== Listen for user edits private ignoreAllEdits() { diff --git a/src/renderer/remote-loader.ts b/src/renderer/remote-loader.ts index ad9fd6c759..023ab6ce99 100644 --- a/src/renderer/remote-loader.ts +++ b/src/renderer/remote-loader.ts @@ -193,7 +193,7 @@ export class RemoteLoader { // contain any supported files. Throw an error to let the user know. if (Object.keys(values).length === 0) { throw new Error( - 'This Gist did not contain any supported files. Supported files must have one of the following extensions: .js, .css, or .html.', + 'This Gist did not contain any supported files. Supported files must have one of the following extensions: .cjs, .js, .mjs, .css, or .html.', ); } diff --git a/src/renderer/runner.ts b/src/renderer/runner.ts index d7612b2fde..f4966fb950 100644 --- a/src/renderer/runner.ts +++ b/src/renderer/runner.ts @@ -1,9 +1,12 @@ +import semver from 'semver'; + import { Bisector } from './bisect'; import { AppState } from './state'; import { maybePlural } from './utils/plural-maybe'; import { FileTransformOperation, InstallState, + MAIN_MJS, PMOperationOptions, PackageJsonOptions, RunResult, @@ -163,6 +166,20 @@ export class Runner { return RunResult.INVALID; } + if ( + semver.lt(ver.version, '28.0.0') && + !ver.version.startsWith('28.0.0-nightly') + ) { + const entryPoint = appState.editorMosaic.mainEntryPointFile(); + + if (entryPoint === MAIN_MJS) { + appState.showErrorDialog( + 'ESM main entry points are only supported starting in Electron 28', + ); + return RunResult.INVALID; + } + } + if (appState.isClearingConsoleOnRun) { appState.clearConsole(); } diff --git a/src/renderer/utils/editor-utils.ts b/src/renderer/utils/editor-utils.ts index da68f153fa..2bfab763dd 100644 --- a/src/renderer/utils/editor-utils.ts +++ b/src/renderer/utils/editor-utils.ts @@ -1,9 +1,9 @@ -import { EditorId, MAIN_JS } from '../../interfaces'; +import { EditorId, MAIN_CJS, MAIN_JS, MAIN_MJS } from '../../interfaces'; import { ensureRequiredFiles, getEmptyContent, getSuffix, - isRequiredFile, + isMainEntryPoint, isSupportedFile, } from '../../utils/editor-utils'; @@ -11,17 +11,23 @@ export { ensureRequiredFiles, getEmptyContent, getSuffix, - isRequiredFile, + isMainEntryPoint, isSupportedFile, }; // The order of these fields is the order that // they'll be sorted in the mosaic const KNOWN_FILES: string[] = [ + MAIN_CJS, MAIN_JS, + MAIN_MJS, + 'renderer.cjs', 'renderer.js', + 'renderer.mjs', 'index.html', + 'preload.cjs', 'preload.js', + 'preload.mjs', 'styles.css', ]; @@ -29,16 +35,31 @@ export function isKnownFile(filename: string): boolean { return KNOWN_FILES.includes(filename); } -const TITLE_MAP = new Map([ - [MAIN_JS, `Main Process (${MAIN_JS})`], - ['renderer.js', 'Renderer Process (renderer.js)'], - ['index.html', 'HTML (index.html)'], - ['preload.js', 'Preload (preload.js)'], - ['styles.css', 'Stylesheet (styles.css)'], -]); - export function getEditorTitle(id: EditorId): string { - return TITLE_MAP.get(id) || id; + switch (id) { + case 'index.html': + return 'HTML (index.html)'; + + case MAIN_CJS: + case MAIN_JS: + case MAIN_MJS: + return `Main Process (${id})`; + + case 'preload.cjs': + case 'preload.js': + case 'preload.mjs': + return `Preload (${id})`; + + case 'renderer.cjs': + case 'renderer.js': + case 'renderer.mjs': + return `Renderer Process (${id})`; + + case 'styles.css': + return 'Stylesheet (styles.css)'; + } + + return id; } // the KNOWN_FILES, in the order of that array, go first. diff --git a/src/renderer/utils/get-package.ts b/src/renderer/utils/get-package.ts index 9ea810b1eb..6c2bcfbaa2 100644 --- a/src/renderer/utils/get-package.ts +++ b/src/renderer/utils/get-package.ts @@ -42,13 +42,15 @@ export async function getPackageJson( } } + const entryPoint = appState.editorMosaic.mainEntryPointFile() ?? MAIN_JS; + return JSON.stringify( { name, productName: name, description: 'My Electron application description', keywords: [], - main: `./${MAIN_JS}`, + main: `./${entryPoint}`, version: '1.0.0', author: appState.packageAuthor, scripts: { diff --git a/src/utils/editor-utils.ts b/src/utils/editor-utils.ts index a28f762346..3a39936a09 100644 --- a/src/utils/editor-utils.ts +++ b/src/utils/editor-utils.ts @@ -1,30 +1,42 @@ -import { EditorId, EditorValues, MAIN_JS } from '../interfaces'; +import { + EditorId, + EditorValues, + MAIN_CJS, + MAIN_JS, + MAIN_MJS, +} from '../interfaces'; -const requiredFiles = new Set([MAIN_JS]); +const mainEntryPointFiles = new Set([MAIN_CJS, MAIN_JS, MAIN_MJS]); -const EMPTY_EDITOR_CONTENT = { - css: '/* Empty */', - html: '', - js: '// Empty', - json: '{}', +const EMPTY_EDITOR_CONTENT: Record = { + '.css': '/* Empty */', + '.html': '', + '.cjs': '// Empty', + '.js': '// Empty', + '.mjs': '// Empty', + '.json': '{}', } as const; export function getEmptyContent(filename: string): string { - return ( - EMPTY_EDITOR_CONTENT[ - getSuffix(filename) as keyof typeof EMPTY_EDITOR_CONTENT - ] || '' - ); + return EMPTY_EDITOR_CONTENT[`.${getSuffix(filename)}` as EditorId] || ''; } -export function isRequiredFile(id: EditorId) { - return requiredFiles.has(id); +export function isMainEntryPoint(id: EditorId) { + return mainEntryPointFiles.has(id); } export function ensureRequiredFiles(values: EditorValues): EditorValues { - for (const file of requiredFiles) { - values[file] ||= getEmptyContent(file); + const mainEntryPoint = Object.keys(values).find((id: EditorId) => + mainEntryPointFiles.has(id), + ) as EditorId | undefined; + + // If no entry point is found, default to main.js + if (!mainEntryPoint) { + values[MAIN_JS] = getEmptyContent(MAIN_JS); + } else { + values[mainEntryPoint] ||= getEmptyContent(mainEntryPoint); } + return values; } @@ -33,5 +45,5 @@ export function getSuffix(filename: string) { } export function isSupportedFile(filename: string): filename is EditorId { - return /\.(css|html|js|json)$/i.test(filename); + return /\.(css|html|cjs|js|mjs|json)$/i.test(filename); } diff --git a/tests/main/menu-spec.ts b/tests/main/menu-spec.ts index ab74e4d4f2..fcb3644069 100644 --- a/tests/main/menu-spec.ts +++ b/tests/main/menu-spec.ts @@ -5,7 +5,7 @@ import * as electron from 'electron'; import { mocked } from 'jest-mock'; -import { BlockableAccelerator } from '../../src/interfaces'; +import { BlockableAccelerator, MAIN_JS } from '../../src/interfaces'; import { IpcEvents } from '../../src/ipc-events'; import { saveFiddle, @@ -271,7 +271,7 @@ describe('menu', () => { }); it('attempts to open a template on click', async () => { - const editorValues = { 'main.js': 'foobar' }; + const editorValues = { [MAIN_JS]: 'foobar' }; mocked(getTemplateValues).mockResolvedValue(editorValues); await showMe.submenu[0].submenu[0].click(); expect(ipcMainManager.send).toHaveBeenCalledWith( diff --git a/tests/main/utils/read-fiddle-spec.ts b/tests/main/utils/read-fiddle-spec.ts index 921559abe3..0e11bc7d3e 100644 --- a/tests/main/utils/read-fiddle-spec.ts +++ b/tests/main/utils/read-fiddle-spec.ts @@ -6,7 +6,9 @@ import { mocked } from 'jest-mock'; import { EditorId, EditorValues, + MAIN_CJS, MAIN_JS, + MAIN_MJS, PACKAGE_NAME, } from '../../../src/interfaces'; import { readFiddle } from '../../../src/main/utils/read-fiddle'; @@ -46,6 +48,22 @@ describe('read-fiddle', () => { expect(fiddle).toStrictEqual({ [MAIN_JS]: getEmptyContent(MAIN_JS) }); }); + it('does not inject main.js if main.cjs or main.mjs present', async () => { + for (const entryPoint of [MAIN_CJS, MAIN_MJS]) { + const mockValues = { + [entryPoint]: getEmptyContent(entryPoint), + }; + setupFSMocks(mockValues); + + const fiddle = await readFiddle(folder); + + expect(console.warn).not.toHaveBeenCalled(); + expect(fiddle).toStrictEqual({ + [entryPoint]: getEmptyContent(entryPoint), + }); + } + }); + it('reads supported files', async () => { const content = 'hello'; const mockValues = { [MAIN_JS]: content }; diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx index bd431f3ab4..4d3b7eb82e 100644 --- a/tests/renderer/app-spec.tsx +++ b/tests/renderer/app-spec.tsx @@ -101,7 +101,7 @@ describe('App component', () => { mocked(openFiddle).mockResolvedValueOnce(undefined); const filePath = '/fake/path'; - const files = { MAIN_JS: 'foo' }; + const files = { [MAIN_JS]: 'foo' }; await app.openFiddle({ localFiddle: { filePath, files } }); expect(openFiddle).toHaveBeenCalledWith(filePath, files); }); diff --git a/tests/renderer/editor-mosaic-spec.ts b/tests/renderer/editor-mosaic-spec.ts index 488f2c4cb1..72e955ddb8 100644 --- a/tests/renderer/editor-mosaic-spec.ts +++ b/tests/renderer/editor-mosaic-spec.ts @@ -392,7 +392,7 @@ describe('EditorMosaic', () => { direction: 'row', first: { direction: 'column', - first: 'main.js', + first: MAIN_JS, second: 'renderer.js', }, second: { diff --git a/tests/renderer/file-manager-spec.ts b/tests/renderer/file-manager-spec.ts index a7fd5ea868..20f116451b 100644 --- a/tests/renderer/file-manager-spec.ts +++ b/tests/renderer/file-manager-spec.ts @@ -1,6 +1,11 @@ import { mocked } from 'jest-mock'; -import { Files, PACKAGE_NAME, SetFiddleOptions } from '../../src/interfaces'; +import { + Files, + MAIN_JS, + PACKAGE_NAME, + SetFiddleOptions, +} from '../../src/interfaces'; import { App } from '../../src/renderer/app'; import { FileManager } from '../../src/renderer/file-manager'; import { dotfilesTransform } from '../../src/renderer/transforms/dotfiles'; @@ -60,7 +65,7 @@ describe('FileManager', () => { it('respects the Electron version specified in package.json', async () => { const pj = { - main: 'main.js', + main: MAIN_JS, devDependencies: { electron: '17.0.0', }, @@ -80,7 +85,7 @@ describe('FileManager', () => { it('correctly adds modules specified in package.json', async () => { const pj = { - main: 'main.js', + main: MAIN_JS, dependencies: { 'meaning-of-life': '*', }, diff --git a/tests/renderer/remote-loader-spec.ts b/tests/renderer/remote-loader-spec.ts index 774e3bc8cf..61d08bf644 100644 --- a/tests/renderer/remote-loader-spec.ts +++ b/tests/renderer/remote-loader-spec.ts @@ -86,7 +86,7 @@ describe('RemoteLoader', () => { it('handles gist fiddle devDependencies', async () => { const gistId = 'pjsontestid'; const pj = { - main: 'main.js', + main: MAIN_JS, devDependencies: { electron: '17.0.0', }, @@ -132,7 +132,7 @@ describe('RemoteLoader', () => { expect(result).toBe(false); expect(store.showErrorDialog).toHaveBeenCalledWith( expect.stringMatching( - /This Gist did not contain any supported files. Supported files must have one of the following extensions: .js, .css, or .html/i, + /This Gist did not contain any supported files. Supported files must have one of the following extensions: .cjs, .js, .mjs, .css, or .html/i, ), ); }); @@ -140,7 +140,7 @@ describe('RemoteLoader', () => { it('sets the Electron version from package.json', async () => { const gistId = 'pjsontestid'; const pj = { - main: 'main.js', + main: MAIN_JS, devDependencies: { electron: '17.0.0', }, @@ -194,7 +194,7 @@ describe('RemoteLoader', () => { it('does not set an invalid Electron version from package.json', async () => { const gistId = 'pjsontestid'; const pj = { - main: 'main.js', + main: MAIN_JS, devDependencies: { electron: '99999.0.0', }, @@ -226,7 +226,7 @@ describe('RemoteLoader', () => { it('handles extra gist fiddle dependencies', async () => { const gistId = 'pjsontestid'; const pj = { - main: 'main.js', + main: MAIN_JS, dependencies: { 'meaning-of-life': '*', }, diff --git a/tests/renderer/utils/editor-utils-spec.ts b/tests/renderer/utils/editor-utils-spec.ts index b1045e7734..23ce4144c8 100644 --- a/tests/renderer/utils/editor-utils-spec.ts +++ b/tests/renderer/utils/editor-utils-spec.ts @@ -1,4 +1,4 @@ -import { MAIN_JS } from '../../../src/interfaces'; +import { MAIN_CJS, MAIN_JS, MAIN_MJS } from '../../../src/interfaces'; import { compareEditors, getEditorTitle, @@ -11,19 +11,21 @@ describe('editor-utils', () => { describe('getEditorTitle', () => { it('recognizes known files', () => { // setup: id is a known file - const id = MAIN_JS; - expect(isKnownFile(id)); - expect(isSupportedFile(id)); + for (const id of [MAIN_CJS, MAIN_JS, MAIN_MJS] as const) { + expect(isKnownFile(id)); + expect(isSupportedFile(id)); - expect(getEditorTitle(id)).toBe('Main Process (main.js)'); + expect(getEditorTitle(id)).toBe(`Main Process (${id})`); + } }); it('recognizes supported files', () => { // set up: id is supported but not known - const id = 'foo.js'; - expect(!isKnownFile(id)); - expect(isSupportedFile(id)); + for (const id of ['foo.cjs', 'foo.js', 'foo.mjs'] as const) { + expect(!isKnownFile(id)); + expect(isSupportedFile(id)); - expect(getEditorTitle(id)).toBe(id); + expect(getEditorTitle(id)).toBe(id); + } }); }); diff --git a/tests/renderer/utils/get-package-spec.ts b/tests/renderer/utils/get-package-spec.ts index 3104d55e87..eeeba06881 100644 --- a/tests/renderer/utils/get-package-spec.ts +++ b/tests/renderer/utils/get-package-spec.ts @@ -1,7 +1,7 @@ import { mocked } from 'jest-mock'; import * as semver from 'semver'; -import { MAIN_JS } from '../../../src/interfaces'; +import { EditorId, MAIN_JS, MAIN_MJS } from '../../../src/interfaces'; import { AppState } from '../../../src/renderer/state'; import { getForgeVersion, @@ -49,9 +49,13 @@ describe('get-package', () => { return JSON.stringify({ ...defaultPackage, ...opts }, null, 2); } - it('getPackageJson() returns a default package.json', async () => { + it('returns a default package.json', async () => { const name = defaultName; const appState = { + editorMosaic: { + files: new Map(), + mainEntryPointFile: () => MAIN_JS, + }, getName: () => name, modules: new Map([['say', '*']]), packageAuthor: defaultAuthor, @@ -60,6 +64,23 @@ describe('get-package', () => { expect(result).toEqual(buildExpectedPackage()); }); + it('can use an ESM entry point', async () => { + const name = defaultName; + const appState = { + editorMosaic: { + files: new Map([[MAIN_MJS, '']]), + mainEntryPointFile: () => MAIN_MJS, + }, + getName: () => name, + modules: new Map([['say', '*']]), + packageAuthor: defaultAuthor, + }; + const result = await getPackageJson(appState as unknown as AppState); + expect(JSON.parse(result)).toMatchObject({ + main: `./${MAIN_MJS}`, + }); + }); + it.each([ ['can include electron', '13.0.0', 'electron'], ['can include electron-nightly', '13.0.0-nightly.12', 'electron-nightly'], diff --git a/tests/utils/editor-utils-spec.ts b/tests/utils/editor-utils-spec.ts index ace8245920..099ed4dbc4 100644 --- a/tests/utils/editor-utils-spec.ts +++ b/tests/utils/editor-utils-spec.ts @@ -1,3 +1,4 @@ +import { MAIN_CJS, MAIN_JS, MAIN_MJS } from '../../src/interfaces'; import { getEmptyContent, getSuffix, @@ -8,7 +9,9 @@ import { createEditorValues } from '../mocks/editor-values'; describe('editor-utils', () => { describe('getEmptyContent', () => { it('returns comments for known types', () => { - expect(getEmptyContent('main.js')).toBe('// Empty'); + for (const id of [MAIN_CJS, MAIN_JS, MAIN_MJS]) { + expect(getEmptyContent(id)).toBe('// Empty'); + } expect(getEmptyContent('styles.css')).toBe('/* Empty */'); expect(getEmptyContent('index.html')).toBe(''); expect(getEmptyContent('data.json')).toBe('{}');