Skip to content

Commit

Permalink
feat: basic support for ESM fiddles (#1474)
Browse files Browse the repository at this point in the history
* feat: basic support for ESM fiddles

* chore: fix lint
  • Loading branch information
dsanders11 authored Nov 27, 2023
1 parent ca0291c commit 5622127
Show file tree
Hide file tree
Showing 17 changed files with 185 additions and 62 deletions.
12 changes: 10 additions & 2 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditorId, string>;

// 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';

Expand Down
6 changes: 3 additions & 3 deletions src/renderer/components/sidebar-file-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,7 +68,7 @@ export const SidebarFileTree = observer(
onClick={() => this.renameEditor(editorId)}
/>
<MenuItem
disabled={isRequiredFile(editorId)}
disabled={isMainEntryPoint(editorId)}
icon="remove"
text="Delete"
intent="danger"
Expand Down Expand Up @@ -201,7 +201,7 @@ export const SidebarFileTree = observer(

if (!isSupportedFile(id)) {
await appState.showErrorDialog(
`Invalid filename "${id}": Must be a file ending in .js, .html, .css, or .json`,
`Invalid filename "${id}": Must be a file ending in .cjs, .js, .mjs, .html, .css, or .json`,
);
return;
}
Expand Down
18 changes: 16 additions & 2 deletions src/renderer/editor-mosaic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MosaicDirection, MosaicNode, getLeaves } from 'react-mosaic-component';
import {
compareEditors,
getEmptyContent,
isMainEntryPoint,
isSupportedFile,
monacoLanguage,
} from './utils/editor-utils';
Expand Down Expand Up @@ -151,7 +152,7 @@ export class EditorMosaic {

if (!isSupportedFile(id)) {
throw new Error(
`Cannot add file "${id}": Must be .js, .html, .css, or .json`,
`Cannot add file "${id}": Must be .cjs, .js, .mjs, .html, .css, or .json`,
);
}

Expand Down Expand Up @@ -275,8 +276,17 @@ export class EditorMosaic {

/** Add a new file to the mosaic */
public addNewFile(id: EditorId, value: string = getEmptyContent(id)) {
if (this.files.has(id))
if (this.files.has(id)) {
throw new Error(`Cannot add file "${id}": File already exists`);
}

const entryPoint = this.mainEntryPointFile();

if (isMainEntryPoint(id) && entryPoint) {
throw new Error(
`Cannot add file "${id}": Main entry point ${entryPoint} exists`,
);
}

this.addFile(id, value);
}
Expand Down Expand Up @@ -318,6 +328,10 @@ export class EditorMosaic {
for (const editor of this.editors.values()) editor.updateOptions(options);
}

public mainEntryPointFile(): EditorId | undefined {
return Array.from(this.files.keys()).find((id) => isMainEntryPoint(id));
}

//=== Listen for user edits

private ignoreAllEdits() {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/remote-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}

Expand Down
17 changes: 17 additions & 0 deletions src/renderer/runner.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -165,6 +168,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();
}
Expand Down
45 changes: 33 additions & 12 deletions src/renderer/utils/editor-utils.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,65 @@
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';

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',
];

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

const TITLE_MAP = new Map<EditorId, string>([
[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.
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/utils/get-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
46 changes: 29 additions & 17 deletions src/utils/editor-utils.ts
Original file line number Diff line number Diff line change
@@ -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<EditorId>([MAIN_JS]);
const mainEntryPointFiles = new Set<EditorId>([MAIN_CJS, MAIN_JS, MAIN_MJS]);

const EMPTY_EDITOR_CONTENT = {
css: '/* Empty */',
html: '<!-- Empty -->',
js: '// Empty',
json: '{}',
const EMPTY_EDITOR_CONTENT: Record<EditorId, string> = {
'.css': '/* Empty */',
'.html': '<!-- Empty -->',
'.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;
}

Expand All @@ -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);
}
4 changes: 2 additions & 2 deletions tests/main/menu-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions tests/main/utils/read-fiddle-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion tests/renderer/app-spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion tests/renderer/editor-mosaic-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ describe('EditorMosaic', () => {
direction: 'row',
first: {
direction: 'column',
first: 'main.js',
first: MAIN_JS,
second: 'renderer.js',
},
second: {
Expand Down
Loading

0 comments on commit 5622127

Please sign in to comment.