diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..6d4b8c237
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,2 @@
+[*]
+end_of_line = crlf
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..13f38072a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+## Standardize line encodings
+## Src: https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings#per-repository-settings
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# Declare files that will always have CRLF line endings on checkout.
+*.js text eol=crlf
+*.ts text eol=crlf
+*.tsx text eol=crlf
+*.md text eol=crlf
+*.cjs text eol=crlf
+*.json text eol=crlf
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 59639f9ca..8740824dc 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,46 +1,46 @@
----
-name: Bug Report
-about: Use this template for highlighting bugs.
-title: '[FEATURE NAME]: [BUG SUMMARY]'
-labels: bug
----
-
-# Prerequisites
-
-Please answer the following questions for yourself before submitting an issue. **YOU MAY DELETE THE PREREQUISITES SECTION.**
-
-- [ ] I am running the latest version
-- [ ] I checked the documentation and found no answer
-- [ ] I checked to make sure that this issue has not already been filed
-
-# Expected Behavior
-
-Please describe the behavior you are expecting
-
-# Current Behavior
-
-What is the current behavior?
-
-# Failure Information (for bugs)
-
-Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
-
-## Steps to Reproduce
-
-Please provide detailed steps for reproducing the issue.
-
-1. step 1
-2. step 2
-3. you get it...
-
-## Context
-
-Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions.
-
-- Version used:
-- Browser Name and version:
-- Operating System and version (desktop or mobile):
-
-## Failure Logs
-
-Please include any relevant log snippets or files here.
+---
+name: Bug Report
+about: Use this template for highlighting bugs.
+title: '[FEATURE NAME]: [BUG SUMMARY]'
+labels: bug
+---
+
+# Prerequisites
+
+Please answer the following questions for yourself before submitting an issue. **YOU MAY DELETE THE PREREQUISITES SECTION.**
+
+- [ ] I am running the latest version
+- [ ] I checked the documentation and found no answer
+- [ ] I checked to make sure that this issue has not already been filed
+
+# Expected Behavior
+
+Please describe the behavior you are expecting
+
+# Current Behavior
+
+What is the current behavior?
+
+# Failure Information (for bugs)
+
+Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
+
+## Steps to Reproduce
+
+Please provide detailed steps for reproducing the issue.
+
+1. step 1
+2. step 2
+3. you get it...
+
+## Context
+
+Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions.
+
+- Version used:
+- Browser Name and version:
+- Operating System and version (desktop or mobile):
+
+## Failure Logs
+
+Please include any relevant log snippets or files here.
diff --git a/.github/ISSUE_TEMPLATE/documentation_request.md b/.github/ISSUE_TEMPLATE/documentation_request.md
index 68b87e70d..c5b917ef0 100644
--- a/.github/ISSUE_TEMPLATE/documentation_request.md
+++ b/.github/ISSUE_TEMPLATE/documentation_request.md
@@ -1,15 +1,15 @@
----
-name: Wiki Documentation Request
-about: Use this template for requesting documentation on a feature in the wiki.
-title: '[Wiki]: [FEATURE]'
-labels: documentation
----
-
-# Feature
-
-Please briefly describe the feature that needs documentation.
-
-## Checklist
-
-- [ ] I checked the documentation and found that it does not already exist
-- [ ] I checked to make sure that this issue has not already been filed
+---
+name: Wiki Documentation Request
+about: Use this template for requesting documentation on a feature in the wiki.
+title: '[Wiki]: [FEATURE]'
+labels: documentation
+---
+
+# Feature
+
+Please briefly describe the feature that needs documentation.
+
+## Checklist
+
+- [ ] I checked the documentation and found that it does not already exist
+- [ ] I checked to make sure that this issue has not already been filed
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index a7ba87d93..6fdaba0a7 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,32 +1,32 @@
-# Description
-
-Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
-
-Fixes # (issue)
-
-## Type of change
-
-Please delete options that are not relevant.
-
-- [ ] Bug fix (non-breaking change which fixes an issue)
-- [ ] New feature (non-breaking change which adds functionality)
-- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
-- [ ] This change requires a documentation update
-
-# How Has This Been Tested?
-
-Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
-
-- [ ] Test A
-- [ ] Test B
-
-# Checklist:
-
-- [ ] My code follows the style guidelines of this project
-- [ ] I have performed a self-review of my own code
-- [ ] I have commented my code, particularly in hard-to-understand areas
-- [ ] I have made corresponding changes to the documentation
-- [ ] My changes generate no new warnings
-- [ ] I have added tests that prove my fix is effective or that my feature works
-- [ ] New and existing unit tests pass locally with my changes
-- [ ] Any dependent changes have been merged and published in downstream modules
+# Description
+
+Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
+
+Fixes # (issue)
+
+## Type of change
+
+Please delete options that are not relevant.
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+
+# How Has This Been Tested?
+
+Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
+
+- [ ] Test A
+- [ ] Test B
+
+# Checklist:
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings
+- [ ] I have added tests that prove my fix is effective or that my feature works
+- [ ] New and existing unit tests pass locally with my changes
+- [ ] Any dependent changes have been merged and published in downstream modules
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 56f94956f..73716b82d 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -16,12 +16,19 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: '16.x'
-
+
- name: Install dependencies
run: yarn install --frozen-lockfile
- - name: Run tests
- run: yarn test && yarn test:scripts
-
- - name: Build all
+ - name: Build bundles and tabs
run: yarn build --tsc --lint
+
+ - name: Test bundles and tabs
+ run: yarn test --color
+
+ - name: Test and lint scripts
+ run: yarn scripts:tsc && yarn scripts:lint && yarn scripts:test --color
+
+ - name: Check and Lint Devserver
+ run: yarn devserver:tsc && yarn devserver:lint
+
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 72215be78..9dc657cb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,11 +2,13 @@
###################
*~
node_modules/
+devserver/node_modules
build/*
/package-lock.json
coverage/
scripts/build/database.json
+scripts/bin.js
# Compiled source #
###################
diff --git a/.husky/pre-commit b/.husky/pre-commit
index b08f47166..ff9540782 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
-yarn build --tsc --lint
+yarn test --color
diff --git a/.husky/pre-push b/.husky/pre-push
index f077c9172..1599930a2 100644
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
-yarn test
+yarn build --lint --tsc
diff --git a/.vscode/launch.json b/.vscode/launch.json
index db4b56848..d27f1b763 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,17 +1,17 @@
-{
- "configurations": [
- {
- "type": "node",
- "name": "Test Scripts",
- "request": "launch",
- "args": [
- "test",
- "--runInBand",
- "--config=${cwd}/scripts/src/jest.config.js"
- ],
- "console": "integratedTerminal",
- "internalConsoleOptions": "neverOpen",
- "runtimeExecutable": "yarn"
- }
- ]
+{
+ "configurations": [
+ {
+ "type": "node",
+ "name": "Test Scripts",
+ "request": "launch",
+ "args": [
+ "test",
+ "--runInBand",
+ "--config=${cwd}/scripts/src/jest.config.js"
+ ],
+ "console": "integratedTerminal",
+ "internalConsoleOptions": "neverOpen",
+ "runtimeExecutable": "yarn"
+ }
+ ]
}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 45cda114a..2629f2e0a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,8 @@
-{
- "eslint.workingDirectories": [
- "src",
- "scripts/src"
- ],
- "files.eol": "\r\n",
+{
+ "eslint.workingDirectories": [
+ "src",
+ "scripts/src",
+ "devserver"
+ ],
+ "files.eol": "\r\n",
}
\ No newline at end of file
diff --git a/__mocks__/chalk.js b/__mocks__/chalk.js
index be0e0afac..a796bfb77 100644
--- a/__mocks__/chalk.js
+++ b/__mocks__/chalk.js
@@ -1,3 +1,3 @@
-export default new Proxy({}, {
- get: () => (input) => input,
+export default new Proxy({}, {
+ get: () => (input) => input,
})
\ No newline at end of file
diff --git a/devserver/.eslintrc.json b/devserver/.eslintrc.json
new file mode 100644
index 000000000..f07262d9c
--- /dev/null
+++ b/devserver/.eslintrc.json
@@ -0,0 +1,23 @@
+{
+ "root": true,
+ "env": { "browser": true, "es2020": true },
+ "extends": [ "../.eslintrc.base.cjs" ],
+ "ignorePatterns": ["dist", ".eslintrc.cjs"],
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["react", "@typescript-eslint"],
+ "rules": {
+ "func-style": 0,
+ "no-empty-function": 0,
+ "@typescript-eslint/no-unused-vars": [
+ 1, // Was 2
+ {
+ // vars: "all",
+ // args: "after-used",
+ // ignoreRestSiblings: false,
+ "argsIgnorePattern": "^_",
+ "caughtErrors": "all", // Was "none"
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ]
+ }
+}
diff --git a/devserver/README.md b/devserver/README.md
new file mode 100644
index 000000000..b43160729
--- /dev/null
+++ b/devserver/README.md
@@ -0,0 +1,5 @@
+# Source Academy Tab Development Server
+
+This server relies on [`Vite`](https://vitejs.dev) to create a server that automatically reloads when it detects file system changes. This allows Source Academy developers to make changes to their tabs without having to use the frontend, and have it render code changes live.
+
+The server is designed to be run using `yarn devserver` from the repository's root directory, hence `vite.config.ts` is not located within this folder.
\ No newline at end of file
diff --git a/devserver/index.html b/devserver/index.html
new file mode 100644
index 000000000..840263ca9
--- /dev/null
+++ b/devserver/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Source Academy Tab Development Server
+
+
+
+
+
+
diff --git a/devserver/public/favicon.ico b/devserver/public/favicon.ico
new file mode 100644
index 000000000..581dfac42
Binary files /dev/null and b/devserver/public/favicon.ico differ
diff --git a/devserver/src/assets/academy_background.jpg b/devserver/src/assets/academy_background.jpg
new file mode 100644
index 000000000..deee933c2
Binary files /dev/null and b/devserver/src/assets/academy_background.jpg differ
diff --git a/devserver/src/components/ControlButton.tsx b/devserver/src/components/ControlButton.tsx
new file mode 100644
index 000000000..eea41eb95
--- /dev/null
+++ b/devserver/src/components/ControlButton.tsx
@@ -0,0 +1,64 @@
+import { AnchorButton, Button, Icon, type IconName, Intent } from "@blueprintjs/core";
+import React from "react";
+
+type ButtonOptions = {
+ className: string;
+ fullWidth: boolean;
+ iconColor?: string;
+ iconOnRight: boolean;
+ intent: Intent;
+ minimal: boolean;
+ type?: "submit" | "reset" | "button";
+};
+
+type ControlButtonProps = {
+ label?: string;
+ icon?: IconName;
+ onClick?: () => void;
+ options?: Partial;
+ isDisabled?: boolean;
+};
+
+const defaultOptions = {
+ className: "",
+ fullWidth: false,
+ iconOnRight: false,
+ intent: Intent.NONE,
+ minimal: true
+};
+
+const ControlButton: React.FC = ({
+ label = "",
+ icon,
+ onClick,
+ options = {},
+ isDisabled = false
+}) => {
+ const buttonOptions: ButtonOptions = {
+ ...defaultOptions,
+ ...options
+ };
+ const iconElement = icon && ;
+ // Refer to #2417 and #2422 for why we conditionally
+ // set the button component. See also:
+ // https://blueprintjs.com/docs/#core/components/button
+ const ButtonComponent = isDisabled ? AnchorButton : Button;
+
+ return (
+
+ {label}
+
+ );
+};
+
+export default ControlButton;
diff --git a/devserver/src/components/Playground.tsx b/devserver/src/components/Playground.tsx
new file mode 100644
index 000000000..be6589e3b
--- /dev/null
+++ b/devserver/src/components/Playground.tsx
@@ -0,0 +1,204 @@
+import { Classes, Intent, OverlayToaster, type ToastProps, type Toaster } from "@blueprintjs/core";
+import classNames from "classnames";
+import { Chapter, Variant } from "js-slang/dist/types";
+import { stringify } from "js-slang/dist/utils/stringify";
+import React, { useCallback } from "react";
+import { HotKeys } from "react-hotkeys";
+
+import Workspace, { type WorkspaceProps } from "./Workspace";
+import { ControlBarRunButton } from "./controlBar/ControlBarRunButton";
+import { type Context, runInContext, getNames, SourceDocumentation } from "js-slang";
+
+// Importing this straight from js-slang doesn't work for whatever reason
+import createContext from "js-slang/dist/createContext";
+
+import { getDynamicTabs } from "./sideContent/utils";
+import type { SideContentTab } from "./sideContent/types";
+import testTabContent from "./sideContent/TestTab";
+import { ControlBarClearButton } from "./controlBar/ControlBarClearButton";
+import { ControlBarRefreshButton } from "./controlBar/ControlBarRefreshButton";
+import type { InterpreterOutput } from "../types";
+import mockModuleContext from "../mockModuleContext";
+
+const refreshSuccessToast: ToastProps = {
+ intent: Intent.SUCCESS,
+ message: "Refresh Successful!"
+};
+
+const errorToast: ToastProps = {
+ intent: Intent.DANGER,
+ message: "An error occurred!"
+};
+
+const evalSuccessToast: ToastProps = {
+ intent: Intent.SUCCESS,
+ message: "Code evaluated successfully!"
+};
+
+const createContextHelper = () => {
+ const tempContext = createContext(Chapter.SOURCE_4, Variant.DEFAULT);
+ return tempContext;
+};
+
+const Playground: React.FC<{}> = () => {
+ const [dynamicTabs, setDynamicTabs] = React.useState([]);
+ const [selectedTabId, setSelectedTab] = React.useState(testTabContent.id);
+ const [codeContext, setCodeContext] = React.useState(createContextHelper());
+ const [editorValue, setEditorValue] = React.useState(localStorage.getItem("editorValue") ?? "");
+ const [replOutput, setReplOutput] = React.useState(null);
+ const [alerts, setAlerts] = React.useState([]);
+
+ const toaster = React.useRef(null);
+
+ const showToast = (props: ToastProps) => {
+ if (toaster.current) {
+ toaster.current.show({
+ ...props,
+ timeout: 1500
+ });
+ }
+ };
+
+ const getAutoComplete = useCallback((row: number, col: number, callback: any) => {
+ getNames(editorValue, row, col, codeContext)
+ .then(([editorNames, displaySuggestions]) => {
+ if (!displaySuggestions) {
+ callback();
+ return;
+ }
+
+ const editorSuggestions = editorNames.map((editorName: any) => ({
+ ...editorName,
+ caption: editorName.name,
+ value: editorName.name,
+ score: editorName.score ? editorName.score + 1000 : 1000,
+ name: undefined
+ }));
+
+ const builtins: Record = SourceDocumentation.builtins[Chapter.SOURCE_4];
+ const builtinSuggestions = Object.entries(builtins)
+ .map(([builtin, thing]) => ({
+ ...thing,
+ caption: builtin,
+ value: builtin,
+ score: 100,
+ name: builtin,
+ docHTML: thing.description
+ }));
+
+ callback(null, [
+ ...builtinSuggestions,
+ ...editorSuggestions
+ ]);
+ });
+ }, [editorValue, codeContext]);
+
+ const loadTabs = () => getDynamicTabs(codeContext)
+ .then((tabs) => {
+ setDynamicTabs(tabs);
+
+ const newIds = tabs.map(({ id }) => id);
+ // If the currently selected tab no longer exists,
+ // switch to the default test tab
+ if (!newIds.includes(selectedTabId)) {
+ setSelectedTab(testTabContent.id);
+ }
+ setAlerts(newIds);
+ })
+ .catch((error) => {
+ showToast(errorToast);
+ console.log(error);
+ });
+
+ const evalCode = () => {
+ codeContext.errors = [];
+ // eslint-disable-next-line no-multi-assign
+ codeContext.moduleContexts = mockModuleContext.moduleContexts = {};
+
+ runInContext(editorValue, codeContext)
+ .then((result) => {
+ if (codeContext.errors.length > 0) {
+ showToast(errorToast);
+ } else {
+ loadTabs()
+ .then(() => showToast(evalSuccessToast));
+ }
+
+ // TODO: Add support for console.log?
+ if (result.status === "finished") {
+ setReplOutput({
+ type: "result",
+ // code: editorValue,
+ consoleLogs: [],
+ value: stringify(result.value)
+ });
+ } else if (result.status === "error") {
+ setReplOutput({
+ type: "errors",
+ errors: codeContext.errors,
+ consoleLogs: []
+ });
+ }
+ });
+ };
+
+ const resetEditor = () => {
+ setCodeContext(createContextHelper());
+ setEditorValue("");
+ localStorage.setItem("editorValue", "");
+ setDynamicTabs([]);
+ setSelectedTab(testTabContent.id);
+ setReplOutput(null);
+ };
+
+ const onRefresh = () => {
+ loadTabs()
+ .then(() => showToast(refreshSuccessToast))
+ .catch(() => showToast(errorToast));
+ };
+
+ const workspaceProps: WorkspaceProps = {
+ controlBarProps: {
+ editorButtons: [
+ ,
+ ,
+
+ ]
+ },
+ replProps: {
+ output: replOutput
+ },
+ handlePromptAutocomplete: getAutoComplete,
+ handleEditorEval: evalCode,
+ handleEditorValueChange(newValue) {
+ setEditorValue(newValue);
+ localStorage.setItem("editorValue", newValue);
+ },
+ editorValue,
+ sideContentProps: {
+ dynamicTabs: [testTabContent, ...dynamicTabs],
+ selectedTabId,
+ onChange: useCallback((newId: string) => {
+ setSelectedTab(newId);
+ setAlerts(alerts.filter((id) => id !== newId));
+ }, [alerts]),
+ alerts
+ }
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default Playground;
diff --git a/devserver/src/components/Workspace.tsx b/devserver/src/components/Workspace.tsx
new file mode 100644
index 000000000..36bd14669
--- /dev/null
+++ b/devserver/src/components/Workspace.tsx
@@ -0,0 +1,144 @@
+import { FocusStyleManager } from "@blueprintjs/core";
+import { type Enable, Resizable, type ResizeCallback } from "re-resizable";
+import React from "react";
+
+import ControlBar, { type ControlBarProps } from "./controlBar/ControlBar";
+import Editor from "./editor/Editor";
+import Repl, { type ReplProps } from "./repl/Repl";
+import SideContent, { type SideContentProps } from "./sideContent/SideContent";
+import { useDimensions } from "./utils/Hooks";
+
+type DispatchProps = {
+ handleEditorEval: () => void;
+ handleEditorValueChange: (newValue: string) => void
+ handlePromptAutocomplete: (row: number, col: number, callback: any) => void
+};
+
+type StateProps = {
+ // Either editorProps or mcqProps must be provided
+ controlBarProps: ControlBarProps;
+ hasUnsavedChanges?: boolean;
+ replProps: ReplProps;
+ sideContentHeight?: number;
+ sideContentIsResizeable?: boolean;
+ editorValue: string
+
+ sideContentProps: SideContentProps
+};
+
+const rightResizeOnly: Enable = { right: true };
+const bottomResizeOnly: Enable = { bottom: true };
+
+export type WorkspaceProps = DispatchProps & StateProps;
+
+const Workspace: React.FC = (props) => {
+ const contentContainerDiv = React.useRef(null);
+ const editorDividerDiv = React.useRef(null);
+ const leftParentResizable = React.useRef(null);
+ const maxDividerHeight = React.useRef(null);
+ const sideDividerDiv = React.useRef(null);
+
+ const [contentContainerWidth] = useDimensions(contentContainerDiv);
+
+ const [sideContentHeight, setSideContentHeight] = React.useState(undefined);
+
+ FocusStyleManager.onlyShowFocusOnTabs();
+
+ React.useEffect(() => {
+ if (props.sideContentIsResizeable && maxDividerHeight.current === null) {
+ maxDividerHeight.current = sideDividerDiv.current!.clientHeight;
+ }
+ });
+
+ /**
+ * Snaps the left-parent resizable to 100% or 0% when percentage width goes
+ * above 95% or below 5% respectively. Also changes the editor divider width
+ * in the case of < 5%.
+ */
+ const toggleEditorDividerDisplay: ResizeCallback = (_a, _b, ref) => {
+ const leftThreshold = 5;
+ const rightThreshold = 95;
+ const editorWidthPercentage
+ = ((ref as HTMLDivElement).clientWidth / contentContainerWidth) * 100;
+ // update resizable size
+ if (editorWidthPercentage > rightThreshold) {
+ leftParentResizable.current!.updateSize({
+ width: "100%",
+ height: "100%"
+ });
+ } else if (editorWidthPercentage < leftThreshold) {
+ leftParentResizable.current!.updateSize({
+ width: "0%",
+ height: "100%"
+ });
+ }
+ };
+
+ /**
+ * Hides the side-content-divider div when side-content is resized downwards
+ * so that it's bottom border snaps flush with editor's bottom border
+ */
+ const toggleDividerDisplay: ResizeCallback = (_a, _b, ref) => {
+ maxDividerHeight.current
+ = sideDividerDiv.current!.clientHeight > maxDividerHeight.current!
+ ? sideDividerDiv.current!.clientHeight
+ : maxDividerHeight.current;
+ const resizableHeight = (ref as HTMLDivElement).clientHeight;
+ const rightParentHeight = (ref.parentNode as HTMLDivElement).clientHeight;
+ if (resizableHeight + maxDividerHeight.current! + 2 > rightParentHeight) {
+ sideDividerDiv.current!.style.display = "none";
+ } else {
+ sideDividerDiv.current!.style.display = "initial";
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {}}
+ handlePromptAutocomplete={props.handlePromptAutocomplete}
+ handleSendReplInputToOutput={() => {}}
+ editorValue={props.editorValue}
+ />
+
+
+
setSideContentHeight(ref.clientHeight)}
+ >
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Workspace;
diff --git a/devserver/src/components/controlBar/ControlBar.tsx b/devserver/src/components/controlBar/ControlBar.tsx
new file mode 100644
index 000000000..52b7175dc
--- /dev/null
+++ b/devserver/src/components/controlBar/ControlBar.tsx
@@ -0,0 +1,37 @@
+import { Classes } from "@blueprintjs/core";
+import classNames from "classnames";
+import React, { type JSX } from "react";
+
+export type ControlBarProps = {
+ editorButtons: Array;
+ flowButtons?: Array;
+ editingWorkspaceButtons?: Array;
+};
+
+const ControlBar: React.FC = (props) => {
+ const editorControl = (
+
+ {props.editorButtons}
+
+ );
+
+ const flowControl = props.flowButtons && (
+ {props.flowButtons}
+ );
+
+ const editingWorkspaceControl = (
+
+ {props.editingWorkspaceButtons}
+
+ );
+
+ return (
+
+ {editorControl}
+ {flowControl}
+ {editingWorkspaceControl}
+
+ );
+};
+
+export default ControlBar;
diff --git a/devserver/src/components/controlBar/ControlBarClearButton.tsx b/devserver/src/components/controlBar/ControlBarClearButton.tsx
new file mode 100644
index 000000000..95ec26220
--- /dev/null
+++ b/devserver/src/components/controlBar/ControlBarClearButton.tsx
@@ -0,0 +1,15 @@
+import { Tooltip2 } from "@blueprintjs/popover2";
+import ControlButton from "../ControlButton";
+import { IconNames } from "@blueprintjs/icons";
+
+type Props = {
+ onClick: () => void
+}
+
+export const ControlBarClearButton = (props: Props) =>
+
+;
diff --git a/devserver/src/components/controlBar/ControlBarRefreshButton.tsx b/devserver/src/components/controlBar/ControlBarRefreshButton.tsx
new file mode 100644
index 000000000..a28cc5376
--- /dev/null
+++ b/devserver/src/components/controlBar/ControlBarRefreshButton.tsx
@@ -0,0 +1,15 @@
+import { Tooltip2 } from "@blueprintjs/popover2";
+import ControlButton from "../ControlButton";
+import { IconNames } from "@blueprintjs/icons";
+
+type Props = {
+ onClick: () => void
+}
+
+export const ControlBarRefreshButton = (props: Props) =>
+
+;
diff --git a/devserver/src/components/controlBar/ControlBarRunButton.tsx b/devserver/src/components/controlBar/ControlBarRunButton.tsx
new file mode 100644
index 000000000..80dfb6e9e
--- /dev/null
+++ b/devserver/src/components/controlBar/ControlBarRunButton.tsx
@@ -0,0 +1,35 @@
+import { Position } from "@blueprintjs/core";
+import { IconNames } from "@blueprintjs/icons";
+import { Tooltip2 } from "@blueprintjs/popover2";
+import React from "react";
+
+import ControlButton from "../ControlButton";
+
+type DispatchProps = {
+ handleEditorEval: () => void;
+};
+
+type StateProps = {
+ key: string;
+ color?: string;
+ className?: string;
+};
+
+type ControlButtonRunButtonProps = DispatchProps & StateProps;
+
+export const ControlBarRunButton: React.FC = (props) => {
+ const tooltipContent = "Evaluate the program";
+ return (
+
+
+
+ );
+};
diff --git a/devserver/src/components/editor/Editor.tsx b/devserver/src/components/editor/Editor.tsx
new file mode 100644
index 000000000..1896a52f5
--- /dev/null
+++ b/devserver/src/components/editor/Editor.tsx
@@ -0,0 +1,188 @@
+import { type Ace, require as acequire } from "ace-builds";
+import "ace-builds/src-noconflict/ext-language_tools";
+import "ace-builds/src-noconflict/ext-searchbox";
+import "ace-builds/src-noconflict/ace";
+import "ace-builds/esm-resolver";
+
+import "js-slang/dist/editors/ace/theme/source";
+
+import React from "react";
+import AceEditor, { type IAceEditorProps } from "react-ace";
+import { HotKeys } from "react-hotkeys";
+
+import type { KeyFunction } from "./EditorHotkeys";
+
+import { getModeString, selectMode } from "../utils/AceHelper";
+
+export type EditorKeyBindingHandlers = { [key in KeyFunction]?: () => void };
+
+type Position = {
+ row: number;
+ column: number;
+};
+
+type DispatchProps = {
+ handleDeclarationNavigate: (cursorPosition: Position) => void;
+ handleEditorEval: () => void;
+ handlePromptAutocomplete: (row: number, col: number, callback: any) => void;
+ handleSendReplInputToOutput?: (newOutput: string) => void;
+};
+
+export type EditorStateProps = {
+ newCursorPosition?: Position;
+ editorValue: string
+ handleEditorValueChange: (newCode: string) => void
+};
+
+export type EditorProps = DispatchProps & EditorStateProps;
+
+const makeCompleter = (handlePromptAutocomplete: DispatchProps["handlePromptAutocomplete"]) => ({
+ getCompletions(
+ _editor: Ace.Editor,
+ _session: Ace.EditSession,
+ pos: Ace.Point,
+ prefix: string,
+ callback: () => void
+ ) {
+ // Don't prompt if prefix starts with number
+ if (prefix && /\d/u.test(prefix.charAt(0))) {
+ callback();
+ return;
+ }
+
+ // Cursor col is insertion location i.e. last char col + 1
+ handlePromptAutocomplete(pos.row + 1, pos.column, callback);
+ }
+});
+
+const moveCursor = (editor: AceEditor["editor"], position: Position) => {
+ editor.selection.clearSelection();
+ editor.moveCursorToPosition(position);
+ editor.renderer.showCursor();
+ editor.renderer.scrollCursorIntoView(position, 0.5);
+};
+
+/* Override handler, so does not trigger when focus is in editor */
+const handlers = {
+ goGreen() {}
+};
+
+const Editor: React.FC = (props: EditorProps) => {
+ const reactAceRef: React.MutableRefObject = React.useRef(null);
+
+ // Refs for things that technically shouldn't change... but just in case.
+ const handlePromptAutocompleteRef = React.useRef(props.handlePromptAutocomplete);
+
+ const editor = reactAceRef.current?.editor;
+
+ // this function defines the Ace language and highlighting mode for the
+ // given combination of chapter, variant and external library. it CANNOT be
+ // put in useEffect as it MUST be called before the mode is set on the Ace
+ // editor, and use(Layout)Effect runs after that happens.
+ //
+ // this used to be in useMemo, but selectMode now checks if the mode is
+ // already defined and doesn't do it, so it is now OK to keep calling this
+ // unconditionally.
+ selectMode();
+
+ React.useLayoutEffect(() => {
+ if (editor === undefined) {
+ return;
+ }
+ // NOTE: Everything in this function is designed to run exactly ONCE per instance of react-ace.
+ // The () => ref.current() are designed to use the latest instance only.
+
+ // Start autocompletion
+ acequire("ace/ext/language_tools")
+ .setCompleters([
+ makeCompleter((...args) => handlePromptAutocompleteRef.current(...args))
+ ]);
+ }, [editor]);
+
+ React.useLayoutEffect(() => {
+ if (editor === undefined) {
+ return;
+ }
+ const newCursorPosition = props.newCursorPosition;
+ if (newCursorPosition) {
+ moveCursor(editor, newCursorPosition);
+ }
+ }, [editor, props.newCursorPosition]);
+
+ const aceEditorProps: IAceEditorProps = {
+ className: "react-ace",
+ editorProps: {
+ $blockScrolling: Infinity
+ },
+ fontSize: 17,
+ height: "100%",
+ highlightActiveLine: false,
+ mode: getModeString(),
+ theme: "source",
+ value: props.editorValue,
+ width: "100%",
+ setOptions: {
+ enableBasicAutocompletion: true,
+ enableLiveAutocompletion: true,
+ fontFamily: "'Inconsolata', 'Consolas', monospace"
+ },
+ // keyboardHandler: props.editorBinding,
+ onChange(newCode) {
+ if (reactAceRef.current) props.handleEditorValueChange(newCode);
+ },
+ commands: [
+ {
+ name: "evaluate",
+ bindKey: {
+ win: "Shift-Enter",
+ mac: "Shift-Enter"
+ },
+ exec: props.handleEditorEval
+ }
+ // {
+ // name: 'navigate',
+ // bindKey: {
+ // win: 'Ctrl-B',
+ // mac: 'Command-B'
+ // }
+ // },
+ // {
+ // name: 'refactor',
+ // bindKey: {
+ // win: 'Ctrl-M',
+ // mac: 'Command-M'
+ // }
+ // },
+ // {
+ // name: 'highlightScope',
+ // bindKey: {
+ // win: 'Ctrl-Shift-H',
+ // mac: 'Command-Shift-H'
+ // },
+ // },
+ // {
+ // name: ' typeInferenceDisplay',
+ // bindKey: {
+ // win: 'Ctrl-Shift-M',
+ // mac: 'Command-Shift-M'
+ // }
+ // }
+ ]
+ // commands: Object.entries(keyHandlers)
+ // .filter(([_, exec]) => exec)
+ // .map(([name, exec]) => ({ name, bindKey: keyBindings[name], exec: exec! }))
+ };
+
+
+ return (
+
+ );
+};
+
+export default Editor;
diff --git a/devserver/src/components/editor/EditorHotkeys.ts b/devserver/src/components/editor/EditorHotkeys.ts
new file mode 100644
index 000000000..9002f99fe
--- /dev/null
+++ b/devserver/src/components/editor/EditorHotkeys.ts
@@ -0,0 +1,24 @@
+export const keyBindings = {
+ evaluate: {
+ win: "Shift-Enter",
+ mac: "Shift-Enter"
+ },
+ navigate: {
+ win: "Ctrl-B",
+ mac: "Command-B"
+ },
+ refactor: {
+ win: "Ctrl-M",
+ mac: "Command-M"
+ },
+ highlightScope: {
+ win: "Ctrl-Shift-H",
+ mac: "Command-Shift-H"
+ },
+ typeInferenceDisplay: {
+ win: "Ctrl-Shift-M",
+ mac: "Command-Shift-M"
+ }
+};
+
+export type KeyFunction = keyof typeof keyBindings;
diff --git a/devserver/src/components/repl/Repl.tsx b/devserver/src/components/repl/Repl.tsx
new file mode 100644
index 000000000..c98a67cf5
--- /dev/null
+++ b/devserver/src/components/repl/Repl.tsx
@@ -0,0 +1,82 @@
+import { Card, Pre } from "@blueprintjs/core";
+import { parseError } from "js-slang";
+import React from "react";
+
+import type { InterpreterOutput } from "../../types";
+
+type OutputProps = {
+ output: InterpreterOutput;
+};
+
+const Output: React.FC = (props: OutputProps) => {
+ switch (props.output.type) {
+ case "code":
+ return (
+
+ {props.output.value}
+
+ );
+ case "running":
+ return (
+
+ {props.output.consoleLogs.join("\n")}
+
+ );
+ case "result":
+ if (props.output.consoleLogs.length === 0) {
+ return (
+
+ {props.output.value}
+
+ );
+ }
+ return (
+
+ {props.output.consoleLogs.join("\n")}
+ {props.output.value}
+
+ );
+
+ case "errors":
+ if (props.output.consoleLogs.length === 0) {
+ return (
+
+ {parseError(props.output.errors)}
+
+ );
+ }
+ return (
+
+ {props.output.consoleLogs.join("\n")}
+
+ {parseError(props.output.errors)}
+
+ );
+
+ default:
+ return '';
+ }
+};
+
+export type ReplProps = {
+ // replButtons: Array;
+ output: InterpreterOutput | null;
+ hidden?: boolean;
+ inputHidden?: boolean;
+ disableScrolling?: boolean;
+};
+
+const Repl: React.FC = (props: ReplProps) => (
+
+
+ {props.output === null
+ ?
+ : }
+ {/* {cards.length > 0 ? cards : ()} */}
+
+
+);
+
+export default Repl;
diff --git a/devserver/src/components/sideContent/SideContent.tsx b/devserver/src/components/sideContent/SideContent.tsx
new file mode 100644
index 000000000..d54e192c7
--- /dev/null
+++ b/devserver/src/components/sideContent/SideContent.tsx
@@ -0,0 +1,100 @@
+import { Card, Icon, Tab, type TabProps, Tabs } from "@blueprintjs/core";
+import { Tooltip2 } from "@blueprintjs/popover2";
+import React from "react";
+import type { SideContentTab } from "./types";
+
+/**
+ * @property onChange A function that is called whenever the
+ * active tab is changed by the user.
+ *
+ * @property tabs An array of SideContentTabs.
+ * The tabs will be rendered in order of the array.
+ * If this array is empty, no tabs will be rendered.
+ *
+ * @property renderActiveTabPanelOnly Set this property to
+ * true to enable unmounting of tab panels whenever tabs are
+ * switched. If it is left undefined, the value will default
+ * to false, and the tab panels will all be loaded with the
+ * mounting of the SideContent component. Switching tabs
+ * will merely hide them from view.
+ */
+export type SideContentProps = {
+ renderActiveTabPanelOnly?: boolean;
+ editorWidth?: string;
+ sideContentHeight?: number;
+ dynamicTabs: SideContentTab[]
+
+ selectedTabId: string
+ alerts: string[]
+ onChange?: (newId: string, oldId: string) => void
+};
+
+const renderTab = (
+ tab: SideContentTab,
+ shouldAlert: boolean,
+ _editorWidth?: string,
+ _sideContentHeight?: number
+) => {
+ const iconSize = 20;
+ const tabTitle = (
+
+
+
+
+
+ );
+ const tabProps: TabProps = {
+ id: tab.id,
+ title: tabTitle,
+ // disabled: tab.disabled,
+ className: "side-content-tab"
+ };
+
+ if (!tab.body) {
+ return ;
+ }
+
+ // const tabBody: JSX.Element = workspaceLocation
+ // ? {
+ // ...tab.body,
+ // props: {
+ // ...tab.body.props,
+ // workspaceLocation,
+ // editorWidth,
+ // sideContentHeight
+ // }
+ // }
+ // : tab.body;
+ const tabPanel: React.JSX.Element = {tab.body}
;
+
+ return ;
+};
+
+const SideContent: React.FC = ({
+ renderActiveTabPanelOnly,
+ editorWidth,
+ sideContentHeight,
+ dynamicTabs,
+ selectedTabId,
+ onChange,
+ alerts
+}) => (
+
+
+
+ {
+ if (onChange) onChange(newId, oldId);
+ }}
+ >
+ {dynamicTabs.map((tab) => renderTab(tab, alerts.includes(tab.id), editorWidth, sideContentHeight))}
+
+
+
+
+);
+
+export default SideContent;
diff --git a/devserver/src/components/sideContent/TestTab.tsx b/devserver/src/components/sideContent/TestTab.tsx
new file mode 100644
index 000000000..645af3ee4
--- /dev/null
+++ b/devserver/src/components/sideContent/TestTab.tsx
@@ -0,0 +1,26 @@
+import { IconNames } from "@blueprintjs/icons";
+import type { SideContentTab } from "./types";
+
+const TestTab = () =>
+
Source Academy Tab Development Server
+
+ Run some code that imports modules in the editor on the left. You should see the corresponding module tab spawn.
+ Whenever you make changes to the tab, the server should automatically reload and show the changes that you've made
+ If that does not happen, you can click the refresh button to manually reload tabs
+
+
;
+
+const testTabContent: SideContentTab = {
+ id: "test",
+ label: "Welcome to the tab development server!",
+ iconName: IconNames.LabTest,
+ body:
+};
+
+export default testTabContent;
diff --git a/devserver/src/components/sideContent/types.ts b/devserver/src/components/sideContent/types.ts
new file mode 100644
index 000000000..1fc40624f
--- /dev/null
+++ b/devserver/src/components/sideContent/types.ts
@@ -0,0 +1,21 @@
+import type { IconName } from "@blueprintjs/icons";
+import type { Context } from "js-slang";
+import type { JSX } from "react";
+
+export type DebuggerContext = {
+ context: Context
+}
+
+export type SideContentTab = {
+ id: string
+ label: string
+ iconName: IconName
+ body: JSX.Element
+}
+
+export type ModuleSideContent = {
+ label: string;
+ iconName: IconName
+ toSpawn?: (context: DebuggerContext) => boolean
+ body: (context: DebuggerContext) => JSX.Element
+}
diff --git a/devserver/src/components/sideContent/utils.ts b/devserver/src/components/sideContent/utils.ts
new file mode 100644
index 000000000..39827d27f
--- /dev/null
+++ b/devserver/src/components/sideContent/utils.ts
@@ -0,0 +1,21 @@
+import type { Context } from "js-slang";
+import manifest from "../../../../modules.json";
+import type { ModuleSideContent, SideContentTab } from "./types";
+
+const moduleManifest = manifest as Record;
+
+export const getDynamicTabs = async (context: Context) => {
+ const moduleSideContents = await Promise.all(Object.keys(context.moduleContexts)
+ .flatMap((moduleName) => moduleManifest[moduleName].tabs.map(async (tabName) => {
+ const { default: rawTab } = await import(`../../../../src/tabs/${tabName}/index.tsx`);
+ return rawTab as ModuleSideContent;
+ })));
+
+ return moduleSideContents.filter(({ toSpawn }) => !toSpawn || toSpawn({ context }))
+ .map((tab): SideContentTab => ({
+ ...tab,
+ // In the frontend, module tabs use their labels as IDs
+ id: tab.label,
+ body: tab.body({ context })
+ }));
+};
diff --git a/devserver/src/components/utils/AceHelper.ts b/devserver/src/components/utils/AceHelper.ts
new file mode 100644
index 000000000..9ab269131
--- /dev/null
+++ b/devserver/src/components/utils/AceHelper.ts
@@ -0,0 +1,29 @@
+/* eslint-disable new-cap */
+import { HighlightRulesSelector, ModeSelector } from "js-slang/dist/editors/ace/modes/source";
+import { Chapter, Variant } from "js-slang/dist/types";
+import ace from "react-ace";
+
+export const getModeString = () => `source${Chapter.SOURCE_4}${Variant.DEFAULT}${""}`;
+
+/**
+ * This _modifies global state_ and defines a new Ace mode globally, if it does not already exist.
+ *
+ * You can call this directly in render functions.
+ */
+export const selectMode = () => {
+ const chapter = Chapter.SOURCE_4;
+ const variant = Variant.DEFAULT;
+ const library = "";
+
+ if (
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ typeof ace.define.modules[`ace/mode/${getModeString(chapter, variant, library)}`]?.Mode
+ === "function"
+ ) {
+ return;
+ }
+
+ HighlightRulesSelector(chapter, variant, library);
+ ModeSelector(chapter, variant, library);
+};
diff --git a/devserver/src/components/utils/Hooks.ts b/devserver/src/components/utils/Hooks.ts
new file mode 100644
index 000000000..57fd8723a
--- /dev/null
+++ b/devserver/src/components/utils/Hooks.ts
@@ -0,0 +1,40 @@
+/**
+ * Dynamically returns the dimensions (width & height) of an HTML element, updating whenever the
+ * element is loaded or resized.
+ *
+ * @param ref A reference to the underlying HTML element.
+ */
+
+import React, { type RefObject } from "react";
+
+export const useDimensions = (ref: RefObject): [width: number, height: number] => {
+ const [width, setWidth] = React.useState(0);
+ const [height, setHeight] = React.useState(0);
+
+ const resizeObserver = React.useMemo(
+ () => new ResizeObserver((entries: ResizeObserverEntry[], _observer: ResizeObserver) => {
+ if (entries.length !== 1) {
+ throw new Error(
+ "Expected only a single HTML element to be observed by the ResizeObserver."
+ );
+ }
+ const contentRect = entries[0].contentRect;
+ setWidth(contentRect.width);
+ setHeight(contentRect.height);
+ }),
+ []
+ );
+
+ React.useEffect(() => {
+ const htmlElement = ref.current;
+ if (htmlElement === null) {
+ return undefined;
+ }
+ resizeObserver.observe(htmlElement);
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [ref, resizeObserver]);
+
+ return [width, height];
+};
diff --git a/devserver/src/main.tsx b/devserver/src/main.tsx
new file mode 100644
index 000000000..ae3e1457e
--- /dev/null
+++ b/devserver/src/main.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import ReactDOM from "react-dom";
+
+import "./styles/index.scss";
+import Playground from "./components/Playground";
+
+ReactDOM.render(
+
+, document.getElementById("root")!);
diff --git a/devserver/src/mockModuleContext.ts b/devserver/src/mockModuleContext.ts
new file mode 100644
index 000000000..82505be11
--- /dev/null
+++ b/devserver/src/mockModuleContext.ts
@@ -0,0 +1,7 @@
+/**
+ * A mock context object to simulate the `js-slang/context` import
+ */
+
+export default {
+ moduleContexts: {}
+};
diff --git a/devserver/src/styles/_application.scss b/devserver/src/styles/_application.scss
new file mode 100644
index 000000000..b276c81e8
--- /dev/null
+++ b/devserver/src/styles/_application.scss
@@ -0,0 +1,40 @@
+/**
+ * Background is repeated here instead of Application, so that
+ * - The background is repeated regardless of content overextending the page
+ * - Application is kept at 100% to prevent things from stetching infinitely
+ * e.g a resizable workspace
+ */
+html {
+ background-size: cover;
+ background-image: url('#{$images-path}/academy_background.jpg');
+ background-repeat: no-repeat;
+ background-attachment: fixed;
+ ::-webkit-scrollbar {
+ height: 5px;
+ width: 6px;
+ }
+ ::-webkit-scrollbar-track {
+ border-radius: 3px;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: $cadet-color-4;
+ border-radius: 3px;
+ }
+}
+
+body {
+ overflow: hidden;
+}
+
+.Application {
+ height: var(--application-height, 100vh);
+ display: flex;
+ flex-direction: column;
+}
+
+.Application__main {
+ height: 100%;
+ display: flex;
+ flex: 1 1 100%;
+ overflow: auto;
+}
diff --git a/devserver/src/styles/_commons.scss b/devserver/src/styles/_commons.scss
new file mode 100644
index 000000000..fffd5c8ff
--- /dev/null
+++ b/devserver/src/styles/_commons.scss
@@ -0,0 +1,98 @@
+.ContentDisplay {
+ height: fit-content;
+ width: 100%;
+
+ &.row {
+ margin-right: 0px;
+ margin-left: 0px;
+ }
+
+ .#{$ns}-non-ideal-state {
+ padding-bottom: 0.7rem;
+
+ > .#{$ns}-non-ideal-state-visual .#{$ns}-icon {
+ display: flex;
+ }
+ }
+
+ .contentdisplay-content-parent {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ padding: 0px;
+
+ .contentdisplay-content {
+ background-color: white;
+ > * {
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+}
+
+.#{$ns}-running-text.md {
+ pre > code {
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ background: transparent;
+ }
+}
+
+/*
+ Breaks IE11 compatability to address inaccessible scrollbar issue
+ of the BlueprintJS Dialog component at:
+ https://github.com/palantir/blueprint/issues/1008
+
+ Follows a working recommendation proposed by a PR in that thread at:
+ https://github.com/palantir/blueprint/pull/3403
+*/
+.#{$ns}-overlay {
+ /* breaks context menu :( */
+ .#{$ns}-overlay-backdrop {
+ position: sticky;
+ height: 100%;
+ width: 100%;
+ }
+
+ .#{$ns}-dialog-container {
+ position: absolute;
+ top: 0;
+ }
+}
+
+.WhiteBackground {
+ background-color: white;
+ padding: 20px;
+ border-radius: 10px;
+}
+
+.Horizontal {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-evenly;
+}
+
+.Vertical {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ align-items: center;
+}
+
+.VerticalStack {
+ > * + * {
+ margin-top: 10px;
+ }
+}
+
+.Centered {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-evenly;
+}
+
+.WaitingCursor {
+ cursor: wait;
+}
diff --git a/devserver/src/styles/_editorTabs.scss b/devserver/src/styles/_editorTabs.scss
new file mode 100644
index 000000000..d2cb8966f
--- /dev/null
+++ b/devserver/src/styles/_editorTabs.scss
@@ -0,0 +1,57 @@
+.editor-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ width: 100%;
+ height: 100%;
+ // Only add right padding for desktop view & not mobile view.
+ .left-parent & {
+ padding-right: 8px;
+ }
+}
+
+.editor-tab-container {
+ display: flex;
+ flex-direction: row;
+ column-gap: 4px;
+ overflow-x: scroll;
+ // Hide scrollbar on Firefox.
+ scrollbar-width: none;
+ // Hide scrollbar on Webkit-based browsers.
+ &::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+ padding-bottom: 4px;
+}
+
+.editor-tab {
+ padding: 5px 5px 5px 10px;
+ // !important is necessary to override the default Card background-color property.
+ background-color: $cadet-color-1 !important;
+ display: flex;
+ flex-direction: row;
+ column-gap: 4px;
+ user-select: none;
+ white-space: nowrap;
+
+ &:hover {
+ // !important is necessary to override the default Card background-color property.
+ background-color: $cadet-color-3 !important;
+ }
+
+ &.selected {
+ // !important is necessary to override the default Card background-color property.
+ background-color: $cadet-color-2 !important;
+ }
+
+ .remove-button {
+ opacity: 25%;
+
+ &:hover {
+ background: black;
+ border-radius: 10px;
+ }
+ }
+}
diff --git a/devserver/src/styles/_global.scss b/devserver/src/styles/_global.scss
new file mode 100644
index 000000000..5922a82b6
--- /dev/null
+++ b/devserver/src/styles/_global.scss
@@ -0,0 +1,43 @@
+// Global variables
+$cadet-color-1: #1a2530;
+$cadet-color-2: #2c3e50;
+$cadet-color-3: #34495e;
+$cadet-color-4: #ced9e0;
+$cadet-color-5: #ffffff;
+
+$images-path: '../assets';
+$achievement-assets: 'https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/achievement';
+
+/*
+ Fixes height behaviour of nested flexboxes in the code editor and REPL,
+ specifically:
+ - On Firefox, fixes the REPL's height exceeding that of its parent container
+ (https://github.com/source-academy/frontend/issues/987)
+ - On Chrome, fixes dead space at the bottom of the code editor when the REPL
+ flexbox overflows
+*/
+.Application__main,
+.WorkspaceParent {
+ min-height: 0;
+}
+
+.#{$ns}-tag {
+ text-align: center;
+}
+
+.badge {
+ left: -15px;
+ position: absolute;
+ top: -15px;
+ z-index: 3;
+}
+
+.#{$ns}-overlay {
+ .#{$ns}-popover2-content {
+ .badge-tag {
+ background-color: transparent;
+ margin-left: 0.4rem;
+ margin-right: 0.4rem;
+ }
+ }
+}
diff --git a/devserver/src/styles/_playground.scss b/devserver/src/styles/_playground.scss
new file mode 100644
index 000000000..efbd25872
--- /dev/null
+++ b/devserver/src/styles/_playground.scss
@@ -0,0 +1,14 @@
+.Playground {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 100%;
+
+ .workspace {
+ .ControlBar {
+ .ControlBar_editingWorkspace {
+ width: 0;
+ }
+ }
+ }
+}
diff --git a/devserver/src/styles/_variableHighlighting.scss b/devserver/src/styles/_variableHighlighting.scss
new file mode 100644
index 000000000..28217efbe
--- /dev/null
+++ b/devserver/src/styles/_variableHighlighting.scss
@@ -0,0 +1,6 @@
+.ace_variable_highlighting {
+ z-index: 4;
+ position: absolute;
+ box-sizing: border-box;
+ border: 1px dashed rgba(255, 255, 255, 0.6);
+}
diff --git a/devserver/src/styles/_workspace.scss b/devserver/src/styles/_workspace.scss
new file mode 100644
index 000000000..f03004779
--- /dev/null
+++ b/devserver/src/styles/_workspace.scss
@@ -0,0 +1,787 @@
+$code-color-code: #ced9e0;
+$code-color-log: #dd8c60;
+$code-color-result: #ffffff;
+$code-color-error: #ff4444;
+
+.workspace {
+ height: 100%;
+ background-color: $cadet-color-1;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+
+ // Hides scrollbar in mobile workspace
+ @media screen and (max-width: 768px) {
+ ::-webkit-scrollbar {
+ display: none;
+ }
+ }
+ .row {
+ margin-right: 0px;
+ margin-left: 0px;
+ }
+
+ .Switch {
+ position: relative;
+ width: 3rem;
+ height: 1rem;
+ left: 1rem;
+ right: 1rem;
+ top: 0.5rem;
+ }
+
+ .ControlBar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+ margin-top: 0.5rem;
+ margin-bottom: 0.6rem;
+
+ @media screen and (max-width: 768px) {
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
+ @media screen and (min-width: 769px) {
+ .ControlBar_editingWorkspace {
+ width: 10%;
+ }
+ }
+ }
+
+ .workspace-parent {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ }
+
+ .content-parent {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex: 1 1 100%;
+ flex-direction: row;
+
+ > div {
+ height: inherit; // Fix for Firefox not autoscrolling when repl overflows
+ }
+ }
+
+ .right-parent {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1;
+ height: 100%;
+ padding-bottom: 0.6rem;
+ overflow: auto;
+ }
+
+ .left-parent {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ padding-bottom: 0.6rem;
+ }
+
+ .editor-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+
+ .editor-divider {
+ flex: initial;
+ }
+
+ .resize-editor-prepend {
+ padding-bottom: 0.2rem;
+ }
+
+ .Editor {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ padding: 0;
+ background-color: $cadet-color-2;
+
+ .editor-react-ace {
+ flex: 1;
+ height: 100%;
+ margin: 2px;
+
+ #brace-editor {
+ height: 100%;
+ }
+ }
+
+ .editor-prepend-react-ace {
+ flex: 1;
+
+ #brace-editor {
+ height: 100%;
+ }
+ }
+
+ .ace_gutter-cell_hi {
+ background-color: blue;
+ }
+
+ .ace_line_hi {
+ background-color: blue;
+ }
+
+ .ace_gutter-cell_hi_agenda {
+ background-color: #32cd32;
+ }
+
+ .ace_line_hi_agenda {
+ background-color: #32cd32;
+ }
+
+ .ace_breakpoint:before {
+ content: ' \25CF';
+ margin-left: -10px;
+ color: red;
+ }
+ }
+
+ .resize-side-content {
+ display: flex;
+ flex-direction: column;
+ /* Prevents side-content from overflowing right-parent container on initial load */
+ max-height: 100%;
+ }
+
+ .resize-editor-content {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .side-content-header {
+ align-items: center;
+ display: flex;
+ flex: none;
+ flex-wrap: wrap;
+ justify-content: center;
+ padding-bottom: 0.2rem;
+ .side-content-header-button:focus {
+ outline: 0;
+ }
+
+ .side-content-header-button-alert {
+ -webkit-animation: alert 1s infinite;
+ -moz-animation: alert 1s infinite;
+ -o-animation: alert 1s infinite;
+ animation: alert 1s infinite;
+ }
+
+ @keyframes alert {
+ 0%,
+ 50% {
+ background-color: rgba(200, 100, 50, 0.5);
+ }
+ 51%,
+ 100% {
+ background-image: rgba(138, 155, 168, 0.3);
+ }
+ }
+ }
+
+ .side-content-divider {
+ height: 0.6rem;
+ flex: initial;
+ }
+
+ .side-content {
+ flex: 1 1 auto;
+ height: 100%;
+ overflow-y: auto;
+
+ .#{$ns}-card {
+ background-color: $cadet-color-2;
+ color: $code-color-result;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ margin: 0 0.5rem 0 0;
+ /* Fix card not wrapping content on OSX, Chrome */
+ overflow-y: auto;
+ padding: 0.4rem 0.6rem 0.4rem 0.6rem;
+
+ .#{$ns}-tabs {
+ width: 100%;
+ }
+ }
+ }
+
+ .side-content-text {
+ height: fit-content;
+ /* word-wrap and word-break are added to make text wrap. */
+ word-wrap: break-word;
+ word-break: break-word;
+ color: $code-color-result;
+ text-align: justify;
+ overflow-x: auto;
+ /* Respect padding of containing bp3 Card when scrollable */
+ margin-bottom: 0.4rem;
+
+ /* If the assessment briefing begins with a header, remove its top margin */
+ & > div > *:first-child {
+ margin-top: 0;
+ }
+
+ & > div > p:last-child {
+ margin-bottom: 1px;
+ }
+
+ .GradingEditor {
+ min-width: 192px;
+
+ .grading-editor-header {
+ text-align: center;
+ }
+
+ .grading-editor-marking-scheme {
+ pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ word-break: break-word;
+ }
+ }
+
+ .grading-editor-container {
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+
+ & > div {
+ padding: 8px 8px 0 8px;
+ flex: 1 1;
+
+ & > div {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-evenly;
+ align-content: center;
+ align-items: baseline;
+
+ & > div {
+ text-align: center;
+ width: 150px;
+ padding: 0 0 16px 0;
+ }
+
+ & > div:first-of-type {
+ padding: 0 0 8px 0;
+ flex-grow: 0;
+ flex-shrink: 0;
+ font-weight: 600;
+ }
+ }
+
+ .adjustment-input {
+ padding: 0 8px 0 8px;
+ width: 150px;
+ }
+
+ .adjustment-input .#{$ns}-intent-danger {
+ background-color: rgba(219, 55, 55, 0.25);
+ }
+ }
+ }
+
+ .react-mde-parent {
+ margin-bottom: 12px;
+ }
+
+ .grading-editor-draft-buttons {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-evenly;
+
+ & > div {
+ width: 50%;
+ }
+ }
+
+ .grading-editor-save-button,
+ .grading-editor-discard-button,
+ .grading-editor-save-continue-button {
+ min-width: 192px;
+ padding: 0 2px;
+ margin-bottom: 6px;
+
+ .grading-editor-button {
+ width: 100%;
+ }
+ }
+
+ .grading-editor-save-continue-button {
+ width: 100%;
+ }
+
+ .grading-editor-last-graded-details {
+ padding: 0 2px;
+ }
+ }
+ }
+
+ .#{$ns}-tab-indicator-wrapper {
+ margin-top: 8px;
+ }
+
+ .side-content-tabs {
+ flex: 1 1 auto;
+ height: 100%;
+ justify-content: center;
+ display: flex;
+
+ /*
+ Fixes an innate issue with bp3's Tabs component where the active tab underline
+ fails to re-compute its position when the browser window changes size
+
+ Also fixes an innate issue where the position of the active tab underline
+ decouples when the central divider is resized
+ */
+ .#{$ns}-tabs {
+ display: flex;
+ flex-direction: column;
+ flex-basis: center;
+
+ .#{$ns}-tab-list {
+ align-self: center;
+ }
+ }
+
+ // Specific CSS for the Stepper and Env Visualiser tab, since REPL is hidden
+ ##{$ns}-tab-panel_side-content-tabs_subst_visualiser,
+ ##{$ns}-tab-panel_side-content-tabs_env_visualizer {
+ height: calc(100% - 60px);
+ margin-top: -45px;
+
+ .side-content-text {
+ height: 100%;
+ margin-top: 70px;
+
+ .sa-substituter {
+ margin: 15px;
+ height: unset;
+
+ .beforeMarker {
+ background: rgba(179, 101, 57, 0.75);
+ position: absolute;
+ z-index: 20;
+ }
+
+ .afterMarker {
+ background: green;
+ position: absolute;
+ z-index: 20;
+ }
+
+ .#{$ns}-slider-label {
+ width: -webkit-max-content;
+ width: -moz-max-content;
+ width: max-content;
+ display: none;
+
+ &:first-child,
+ &:last-child {
+ display: inline;
+ }
+ }
+
+ .#{$ns}-card {
+ background-color: $cadet-color-1;
+ padding: 0.4rem 0.6rem 0.4rem 0.6rem;
+ margin: 2rem 0 0.5rem 0;
+
+ pre {
+ background-color: transparent;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ color: $code-color-result;
+ padding: 0px;
+ margin: 0px;
+ text-align: left;
+ /**
+ * white-space, word-wrap and word-break
+ * are specified to allow all output to wrap.
+ */
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ word-break: break-word;
+ /**
+ * Use same fonts as ace-editor for
+ * output. Taken from react-ace
+ * sourcecode, font size modified.
+ */
+ font: 16px / normal 'Inconsolata', 'Consolas', monospace;
+
+ .canvas-container {
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ padding: 0.5rem 0 0.5rem 0;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ }
+
+ canvas {
+ height: 20rem;
+ width: 20rem;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .side-content-tab {
+ // Set colour of icons in blueprintjs tabs
+ color: #a7b6c2;
+
+ &[aria-selected='true'] {
+ .side-content-tooltip {
+ background-color: #495a6b;
+
+ // Hide alert animation when the tab is currently selected
+ // (the alert will be cleared by the code if any tab change occurs)
+ -webkit-animation: none;
+ -moz-animation: none;
+ -o-animation: none;
+ animation: none;
+ }
+ }
+
+ &[aria-disabled='true'] {
+ .side-content-tooltip {
+ // Set tooltip colour to always be the same as the background
+ background-color: inherit;
+ // Set colour of icons to be more faded
+ color: #3b4d5e;
+
+ // Prevent alert animation for disabled tabs
+ -webkit-animation: none;
+ -moz-animation: none;
+ -o-animation: none;
+ animation: none;
+ }
+ }
+
+ .sa-video {
+ min-width: min-content;
+
+ .sa-video-header {
+ justify-content: space-around;
+ display: flex;
+ background: rgba(26, 37, 48, 0.5);
+ border-radius: 3px 3px 0 0;
+
+ .sa-video-header-element {
+ display: inherit;
+ padding: 5px 0px;
+
+ .#{$ns}-button-group {
+ width: max-content;
+
+ .#{$ns}-button.sa-live-video-button.#{$ns}-active {
+ pointer-events: none;
+ }
+ }
+
+ .sa-video-header-numeric-input {
+ margin-left: 5px;
+ margin-right: 5px;
+ }
+ }
+
+ .#{$ns}-divider {
+ margin: 0;
+ }
+ }
+
+ .sa-video-element {
+ width: 100%;
+ text-align: center;
+ padding: 10px;
+ background: $cadet-color-1;
+ border-radius: 0 0 3px 3px;
+ }
+ }
+
+ .sa-remote-execution {
+ margin: 6px;
+
+ .devices-menu-container {
+ max-height: 60vh;
+ overflow-y: auto;
+ .#{$ns}-menu {
+ margin: 1px;
+ box-sizing: border-box;
+
+ .edit-buttons {
+ display: inline-block;
+ margin-left: 4px;
+
+ .#{$ns}-button.#{$ns}-small {
+ margin-top: -7px;
+ margin-bottom: -3px;
+ }
+ }
+ }
+ }
+ }
+
+ .sa-html-display {
+ background-color: white;
+ width: 100%;
+ height: 60vh;
+ }
+ }
+
+ .sidecontent-overview {
+ // fix overflow for assessment sidecontent
+
+ pre {
+ // for code block in pre
+ overflow-x: auto;
+ }
+
+ p {
+ // for image in p
+ img {
+ max-width: 100%;
+ object-fit: contain;
+ }
+ }
+ }
+
+ .side-content-tooltip {
+ border-radius: 3px;
+ // size of rounded box under tab icon
+ height: 25px;
+ width: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &:hover {
+ background-color: #3a4c5d;
+ }
+
+ &.side-content-tab-alert {
+ -webkit-animation: alert 1s infinite;
+ -moz-animation: alert 1s infinite;
+ -o-animation: alert 1s infinite;
+ animation: alert 1s infinite;
+ }
+
+ @keyframes alert {
+ 0%,
+ 50% {
+ background-color: rgba(200, 100, 50, 0.5);
+ }
+ 51%,
+ 100% {
+ background-image: rgba(138, 155, 168, 0.3);
+ }
+ }
+
+ &.side-content-tab-alert-error {
+ -webkit-animation: error 1s infinite;
+ -moz-animation: error 1s infinite;
+ -o-animation: error 1s infinite;
+ animation: error 1s infinite;
+ }
+
+ @keyframes error {
+ 0%,
+ 50% {
+ background-color: rgb(255, 68, 68);
+ }
+ 51%,
+ 100% {
+ background-image: rgba(102, 42, 50, 0.3);
+ }
+ }
+ .side-content-text .slider {
+ $a: 100%;
+ $b: 140px;
+ width: calc(#{$a} - #{$b});
+ }
+ }
+
+ .resize-editor {
+ display: flex;
+ flex-direction: row;
+
+ .Editor {
+ flex: 1 1 auto;
+ margin: 0 0.5rem 0 0;
+ padding: 0;
+ }
+
+ .editor-content {
+ flex: 1 1 auto;
+ padding: 0;
+ }
+ }
+
+ .#{$ns}-divider {
+ margin: 0 0 0.5rem 0;
+ }
+
+ .Repl {
+ display: flex;
+ flex: 1 1;
+ flex-direction: column;
+ overflow-x: visible;
+ overflow-y: auto;
+ margin: 0 0.5rem 0 0;
+
+ .#{$ns}-card {
+ background-color: $cadet-color-2;
+ padding: 0.4rem 0.6rem 0.4rem 0.6rem;
+ margin: 0 0 0.5rem 0;
+
+ pre {
+ background-color: transparent;
+ box-shadow: none;
+ color: inherit;
+ padding: 0px;
+ margin: 0px;
+ /**
+ * white-space, word-wrap and word-break
+ * are specified to allow all output to wrap.
+ */
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ word-break: break-word;
+ /**
+ * Use same fonts as ace-editor for
+ * output. Taken from react-ace
+ * sourcecode, font size modified.
+ */
+ font: 16px / normal 'Inconsolata', 'Consolas', monospace;
+ }
+
+ .code-output {
+ color: $code-color-code;
+ }
+
+ .log-output {
+ color: $code-color-log;
+ }
+
+ .result-output {
+ color: $code-color-result;
+
+ .canvas-container {
+ display: flex;
+ padding: 0.5rem 0 0.5rem 0;
+ align-items: center;
+ justify-content: center;
+ }
+
+ canvas {
+ height: 20rem;
+ width: 20rem;
+ }
+ }
+
+ .error-output {
+ color: $code-color-error;
+ }
+ }
+
+ /* flush to align with editor bottom */
+ .repl-input-parent {
+ padding: 0;
+ margin-bottom: 0rem;
+ flex-wrap: nowrap;
+ }
+
+ .repl-react-ace {
+ margin: 0.4rem 0.6rem 0.4rem 0.6rem;
+ }
+
+ // .repl-react-ace-green {
+ // background: $pure-green !important;
+ // margin: 0.4rem 0.6rem 0.4rem 0.6rem;
+ // }
+ }
+
+ .react-ace {
+ background-color: $cadet-color-2;
+ border-radius: 3px;
+ .ace_gutter {
+ background: $cadet-color-3;
+ color: rgb(128, 145, 160);
+ }
+ }
+
+ // .react-ace-green {
+ // background-color: $pure-green !important;
+ // border-radius: 3px;
+ // .ace_gutter {
+ // background: $dark-green;
+ // color: rgb(128, 145, 160);
+ // }
+ // }
+
+ .item {
+ /* Nested Blueprint Card component for Contest Entry Card */
+ margin: 0;
+ .#{$ns}-card {
+ margin: 0;
+ border-radius: 0;
+ .contestentry-entryid {
+ margin: 0;
+ text-align: center;
+ }
+ }
+ }
+}
+
+.Popover-share {
+ .#{$ns}-popover2-arrow-fill {
+ fill: $cadet-color-4;
+ }
+
+ .#{$ns}-popover2-content {
+ background: $cadet-color-4;
+ display: flex;
+ padding: 0.4rem 0.8rem 0.4rem 0.8rem;
+
+ input {
+ width: 15rem;
+ margin-right: 0.58em;
+ &:focus {
+ outline: none;
+ }
+ }
+
+ button {
+ padding: 5px 5px 5px 10px;
+ }
+ }
+}
+
+/* otherwise, a thick outline will show on click due to react-hotkeys */
+.workspace:focus {
+ outline: 0;
+}
diff --git a/devserver/src/styles/index.scss b/devserver/src/styles/index.scss
new file mode 100644
index 000000000..5fc22cb31
--- /dev/null
+++ b/devserver/src/styles/index.scss
@@ -0,0 +1,19 @@
+@use 'sass:math';
+
+@import '@blueprintjs/core/lib/css/blueprint.css';
+@import '@blueprintjs/popover2/lib/css/blueprint-popover2.css';
+@import '@blueprintjs/core/lib/scss/variables';
+
+// CSS styles for react-mde Markdown editor
+// (currently this editor is only used for grading comments)
+// react-mde-preview.css is excluded to avoid conflict with blueprintjs
+// styles in the preview tab of the editor, providing a more accurate
+// depiction of what the actual comment will look like
+
+@import 'global';
+
+@import 'application';
+@import 'commons';
+@import 'editorTabs';
+@import 'playground';
+@import 'workspace';
diff --git a/devserver/src/types.ts b/devserver/src/types.ts
new file mode 100644
index 000000000..d806c9c96
--- /dev/null
+++ b/devserver/src/types.ts
@@ -0,0 +1,47 @@
+import type { SourceError } from "js-slang/dist/types";
+
+/**
+ * An output while the program is still being run in the interpreter. As a
+ * result, there are no return values or SourceErrors yet. However, there could
+ * have been calls to display (console.log) that need to be printed out.
+ */
+export type RunningOutput = {
+ type: "running";
+ consoleLogs: string[];
+};
+
+/**
+ * An output which reflects the program which the user had entered. Not a true
+ * Output from the interpreter, but simply there to let he user know what had
+ * been entered.
+ */
+export type CodeOutput = {
+ type: "code";
+ value: string;
+};
+
+/**
+ * An output which represents a program being run successfully, i.e. with a
+ * return value at the end. A program can have either a return value, or errors,
+ * but not both.
+ */
+export type ResultOutput = {
+ type: "result";
+ value: any;
+ consoleLogs: string[];
+ runtime?: number;
+ isProgram?: boolean;
+};
+
+/**
+ * An output which represents a program being run unsuccessfully, i.e. with
+ * errors at the end. A program can have either a return value, or errors, but
+ * not both.
+ */
+export type ErrorOutput = {
+ type: "errors";
+ errors: SourceError[];
+ consoleLogs: string[];
+};
+
+export type InterpreterOutput = RunningOutput | CodeOutput | ResultOutput | ErrorOutput;
diff --git a/devserver/src/vite-env.d.ts b/devserver/src/vite-env.d.ts
new file mode 100644
index 000000000..14fa74923
--- /dev/null
+++ b/devserver/src/vite-env.d.ts
@@ -0,0 +1 @@
+// /
diff --git a/devserver/tsconfig.json b/devserver/tsconfig.json
new file mode 100644
index 000000000..405ed40f9
--- /dev/null
+++ b/devserver/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/devserver/tsconfig.node.json b/devserver/tsconfig.node.json
new file mode 100644
index 000000000..7d55d4e27
--- /dev/null
+++ b/devserver/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["../vite.config.ts"]
+}
diff --git a/package.json b/package.json
index 6d9a0a775..01d3d45a2 100644
--- a/package.json
+++ b/package.json
@@ -7,14 +7,17 @@
"scripts-info": {
"//NOTE": "Run `npm i npm-scripts-info -g` to install once globally, then run `npm-scripts-info` as needed to list these descriptions",
"create": "Interactively initialise a new bundle or tab from their templates",
+ "devserver": "Start the tab development server",
+ "devserver:lint": "Lint code related to the dev server",
+ "devserver:tsc": "Run tsc over dev server code",
"docs": "Build only documentation",
"lint": "Lint bundle and tab code",
- "lint:scripts": "Lint build script code",
"build": "Lint code, then build modules and documentation",
"build:help": "Show help for the build scripts",
- "build:scripts": "Compile build scripts",
"serve": "Start the HTTP server to serve all files in `build/`, with the same directory structure",
"scripts": "Run a script within the scripts directory",
+ "scripts:build": "Compile build scripts",
+ "scripts:lint": "Lint build script code",
"prepare": "Enable git hooks",
"test": "Run unit tests",
"test:watch": "Watch files for changes and rerun tests related to changed files",
@@ -24,42 +27,50 @@
},
"type": "module",
"scripts": {
+ "build": "yarn scripts build",
+ "build:help": "yarn scripts build --help",
"create": "yarn scripts create",
+ "dev": "yarn scripts build modules && yarn serve",
"docs": "yarn scripts build docs",
"lint": "yarn scripts lint",
- "lint:scripts": "./node_modules/.bin/eslint -c scripts/src/.eslintrc.cjs scripts/src",
- "build": "yarn scripts build",
- "build:help": "yarn scripts build --help",
- "build:scripts": "yarn test:scripts && yarn lint:scripts && rimraf scripts/bin && tsc --project scripts/src/tsconfig.json && copyfiles -f \"scripts/src/templates/templates/*\" scripts/bin/templates/templates",
- "serve": "http-server --cors=* -c-1 -p 8022 ./build",
- "scripts": "node --no-warnings --max-old-space-size=8192 scripts/bin/index.js",
"prepare": "husky install",
- "test": "jest --verbose --config=src/jest.config.js",
- "test:scripts": "jest --config=scripts/src/jest.config.js",
- "test:watch": "jest --watch",
- "dev": "yarn scripts build modules && yarn serve",
+ "postinstall": "patch-package && yarn scripts:build",
+ "scripts": "node --max-old-space-size=4096 scripts/bin.js",
+ "serve": "http-server --cors=* -c-1 -p 8022 ./build",
+ "test": "yarn scripts test",
+ "test:all": "yarn test && yarn scripts:test",
+ "test:watch": "yarn scripts test --watch",
"watch": "yarn scripts watch",
- "postinstall": "patch-package"
+ "devserver": "vite",
+ "devserver:lint": "yarn scripts devserver lint",
+ "devserver:tsc": "tsc --project devserver/tsconfig.json",
+ "scripts:all": "node scripts/scripts_manager.js",
+ "scripts:build": "node scripts/scripts_manager.js build",
+ "scripts:lint": "node scripts/scripts_manager.js lint",
+ "scripts:tsc": "tsc --project scripts/src/tsconfig.json",
+ "scripts:test": "node scripts/scripts_manager.js test"
},
"devDependencies": {
"@types/dom-mediacapture-record": "^1.0.11",
"@types/eslint": "^8.4.10",
"@types/estree": "^1.0.0",
"@types/jest": "^27.4.1",
- "@types/node": "^17.0.23",
+ "@types/lodash": "^4.14.198",
+ "@types/node": "^20.8.9",
"@types/plotly.js-dist": "npm:@types/plotly.js",
- "@types/react": "^17.0.43",
- "@typescript-eslint/eslint-plugin": "^5.47.1",
- "@typescript-eslint/parser": "^5.47.1",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@typescript-eslint/eslint-plugin": "^6.6.0",
+ "@typescript-eslint/parser": "^6.6.0",
+ "@vitejs/plugin-react": "^4.0.4",
"acorn": "^8.8.1",
"acorn-jsx": "^5.3.2",
"astring": "^1.8.4",
"chalk": "^5.0.1",
"commander": "^9.4.0",
"console-table-printer": "^2.11.1",
- "copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
- "esbuild": "^0.17.19",
+ "esbuild": "^0.18.20",
"eslint": "^8.21.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
@@ -74,14 +85,18 @@
"husky": "5",
"jest": "^29.4.1",
"jest-environment-jsdom": "^29.4.1",
- "rimraf": "^4.1.2",
- "ts-jest": "^29.0.5",
- "typedoc": "^0.23.23",
- "typescript": "4.8",
+ "re-resizable": "^6.9.11",
+ "react-hotkeys": "^2.0.0",
+ "react-responsive": "^9.0.2",
+ "sass": "^1.66.1",
+ "ts-jest": "^29.1.1",
+ "typedoc": "^0.25.1",
+ "typescript": "5.0",
+ "vite": "^4.4.9",
"yarnhook": "^0.5.1"
},
"dependencies": {
- "@blueprintjs/core": "^4.6.1",
+ "@blueprintjs/core": "^4.20.2",
"@blueprintjs/icons": "^4.4.0",
"@blueprintjs/popover2": "^1.4.3",
"@box2d/core": "^0.10.0",
@@ -89,18 +104,19 @@
"@jscad/modeling": "2.9.6",
"@jscad/regl-renderer": "^2.6.1",
"@jscad/stl-serializer": "^2.1.13",
- "ace-builds": "^1.4.14",
+ "ace-builds": "^1.25.1",
"classnames": "^2.3.1",
"dayjs": "^1.10.4",
"gl-matrix": "^3.3.0",
"js-slang": "^1.0.20",
+ "lodash": "^4.17.21",
"patch-package": "^6.5.1",
"phaser": "^3.54.0",
"plotly.js-dist": "^2.17.1",
"postinstall-postinstall": "^2.1.0",
- "react": "^17.0.2",
+ "react": "^18.2.0",
"react-ace": "^10.1.0",
- "react-dom": "^17.0.2",
+ "react-dom": "^18.2.0",
"regl": "^2.1.0",
"save-file": "^2.3.1",
"source-academy-utils": "^1.0.0",
@@ -112,5 +128,9 @@
"src/jest.config.js",
"scripts/src/jest.config.js"
]
+ },
+ "resolutions": {
+ "@types/react": "^18.2.0",
+ "esbuild": "^0.18.20"
}
}
diff --git a/scripts/README.md b/scripts/README.md
index eae547359..28cd96bf1 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1,4 +1,4 @@
# Scripts
The script system uses [`commander`](https://github.com/tj/commander.js) to parse command line options and run the corresponding Javascript code.
-The source code for the scripts is located in `src` and written in Typescript. Run `yarn build:scripts` to compile the scripts into Javascript if necessary. The compiled code is present in `bin`.
\ No newline at end of file
+The source code for the scripts is located in `src` and written in Typescript. Run `yarn scripts:build` to compile the scripts into Javascript if necessary.
\ No newline at end of file
diff --git a/scripts/bin/build/buildUtils.js b/scripts/bin/build/buildUtils.js
deleted file mode 100644
index 96ed5fca7..000000000
--- a/scripts/bin/build/buildUtils.js
+++ /dev/null
@@ -1,267 +0,0 @@
-import chalk from 'chalk';
-import { Command, Option } from 'commander';
-import { Table } from 'console-table-printer';
-import fs from 'fs/promises';
-import path from 'path';
-import { retrieveManifest } from '../scriptUtils.js';
-import { Assets, } from './types.js';
-export const divideAndRound = (dividend, divisor, round = 2) => (dividend / divisor).toFixed(round);
-export const fileSizeFormatter = (size) => {
- if (typeof size !== 'number')
- return '-';
- size /= 1000;
- if (size < 0.01)
- return '<0.01 KB';
- if (size >= 100)
- return `${divideAndRound(size, 1000)} MB`;
- return `${size.toFixed(2)} KB`;
-};
-export const logResult = (unreduced, verbose) => {
- const overallResult = unreduced.reduce((res, [type, name, entry]) => {
- if (!res[type]) {
- res[type] = {
- severity: 'success',
- results: {},
- };
- }
- if (entry.severity === 'error')
- res[type].severity = 'error';
- else if (res[type].severity === 'success' && entry.severity === 'warn')
- res[type].severity = 'warn';
- res[type].results[name] = entry;
- return res;
- }, {});
- return console.log(Object.entries(overallResult)
- .map(([label, toLog]) => {
- if (!toLog)
- return null;
- const upperCaseLabel = label[0].toUpperCase() + label.slice(1);
- const { severity: overallSev, results } = toLog;
- const entries = Object.entries(results);
- if (entries.length === 0)
- return '';
- if (!verbose) {
- if (overallSev === 'success') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}\n`;
- }
- if (overallSev === 'warn') {
- return chalk.cyanBright(`${upperCaseLabel}s built with ${chalk.yellowBright('warnings')}:\n${entries
- .filter(([, { severity }]) => severity === 'warn')
- .map(([bundle, { error }], i) => chalk.yellowBright(`${i + 1}. ${bundle}: ${error}`))
- .join('\n')}\n`);
- }
- return chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors:\n${entries
- .filter(([, { severity }]) => severity !== 'success')
- .map(([bundle, { error, severity }], i) => (severity === 'error'
- ? chalk.redBright(`${i + 1}. Error ${bundle}: ${error}`)
- : chalk.yellowBright(`${i + 1}. Warning ${bundle}: +${error}`)))
- .join('\n')}\n`);
- }
- const outputTable = new Table({
- columns: [{
- name: 'name',
- title: upperCaseLabel,
- },
- {
- name: 'severity',
- title: 'Status',
- },
- {
- name: 'elapsed',
- title: 'Elapsed (s)',
- },
- {
- name: 'fileSize',
- title: 'File Size',
- },
- {
- name: 'error',
- title: 'Errors',
- }],
- });
- entries.forEach(([name, { elapsed, severity, error, fileSize }]) => {
- if (severity === 'error') {
- outputTable.addRow({
- name,
- elapsed: '-',
- error,
- fileSize: '-',
- severity: 'Error',
- }, { color: 'red' });
- }
- else if (severity === 'warn') {
- outputTable.addRow({
- name,
- elapsed: divideAndRound(elapsed, 1000, 2),
- error,
- fileSize: fileSizeFormatter(fileSize),
- severity: 'Warning',
- }, { color: 'yellow' });
- }
- else {
- outputTable.addRow({
- name,
- elapsed: divideAndRound(elapsed, 1000, 2),
- error: '-',
- fileSize: fileSizeFormatter(fileSize),
- severity: 'Success',
- }, { color: 'green' });
- }
- });
- if (overallSev === 'success') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}:\n${outputTable.render()}\n`;
- }
- if (overallSev === 'warn') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} with ${chalk.yellowBright('warnings')}:\n${outputTable.render()}\n`;
- }
- return `${chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors`)}:\n${outputTable.render()}\n`;
- })
- .filter((str) => str !== null)
- .join('\n'));
-};
-/**
- * Call this function to exit with code 1 when there are errors with the build command that ran
- */
-export const exitOnError = (results, ...others) => {
- results.concat(others)
- .forEach((entry) => {
- if (!entry)
- return;
- if (Array.isArray(entry)) {
- const [, , { severity }] = entry;
- if (severity === 'error')
- process.exit(1);
- }
- else if (entry.severity === 'error')
- process.exit(1);
- });
-};
-export const retrieveTabs = async (manifestFile, tabs) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownTabs = Object.values(manifest)
- .flatMap((x) => x.tabs);
- if (tabs === null) {
- tabs = knownTabs;
- }
- else {
- const unknownTabs = tabs.filter((t) => !knownTabs.includes(t));
- if (unknownTabs.length > 0) {
- throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
- }
- }
- return tabs;
-};
-export const retrieveBundles = async (manifestFile, modules) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownBundles = Object.keys(manifest);
- if (modules !== null) {
- // Some modules were specified
- const unknownModules = modules.filter((m) => !knownBundles.includes(m));
- if (unknownModules.length > 0) {
- throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
- }
- return modules;
- }
- return knownBundles;
-};
-/**
- * Determines which bundles and tabs to build based on the user's input.
- *
- * If no modules and no tabs are specified, it is assumed the user wants to
- * build everything.
- *
- * If modules but no tabs are specified, it is assumed the user only wants to
- * build those bundles (and possibly those modules' tabs based on
- * shouldAddModuleTabs).
- *
- * If tabs but no modules are specified, it is assumed the user only wants to
- * build those tabs.
- *
- * If both modules and tabs are specified, both of the above apply and are
- * combined.
- *
- * @param modules module names specified by the user
- * @param tabOptions tab names specified by the user
- * @param shouldAddModuleTabs whether to also automatically include the tabs of
- * specified modules
- */
-export const retrieveBundlesAndTabs = async (manifestFile, modules, tabOptions, shouldAddModuleTabs = true) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownBundles = Object.keys(manifest);
- const knownTabs = Object
- .values(manifest)
- .flatMap((x) => x.tabs);
- let bundles = [];
- let tabs = [];
- function addSpecificModules() {
- // If unknown modules were specified, error
- const unknownModules = modules.filter((m) => !knownBundles.includes(m));
- if (unknownModules.length > 0) {
- throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
- }
- bundles = bundles.concat(modules);
- if (shouldAddModuleTabs) {
- // Add the modules' tabs too
- tabs = [...tabs, ...modules.flatMap((bundle) => manifest[bundle].tabs)];
- }
- }
- function addSpecificTabs() {
- // If unknown tabs were specified, error
- const unknownTabs = tabOptions.filter((t) => !knownTabs.includes(t));
- if (unknownTabs.length > 0) {
- throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
- }
- tabs = tabs.concat(tabOptions);
- }
- function addAllBundles() {
- bundles = bundles.concat(knownBundles);
- }
- function addAllTabs() {
- tabs = tabs.concat(knownTabs);
- }
- if (modules === null && tabOptions === null) {
- addAllBundles();
- addAllTabs();
- }
- else {
- if (modules !== null)
- addSpecificModules();
- if (tabOptions !== null)
- addSpecificTabs();
- }
- return {
- bundles: [...new Set(bundles)],
- tabs: [...new Set(tabs)],
- modulesSpecified: modules !== null,
- };
-};
-export const bundleNameExpander = (srcdir) => (name) => path.join(srcdir, 'bundles', name, 'index.ts');
-export const tabNameExpander = (srcdir) => (name) => path.join(srcdir, 'tabs', name, 'index.tsx');
-export const createBuildCommand = (label, addLint) => {
- const cmd = new Command(label)
- .option('--outDir ', 'Output directory', 'build')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-v, --verbose', 'Display more information about the build results', false);
- if (addLint) {
- cmd.option('--tsc', 'Run tsc before building')
- .option('--lint', 'Run eslint before building')
- .addOption(new Option('--fix', 'Ask eslint to autofix linting errors')
- .implies({ lint: true }));
- }
- return cmd;
-};
-/**
- * Create the output directory's root folder
- */
-export const createOutDir = (outDir) => fs.mkdir(outDir, { recursive: true });
-/**
- * Copy the manifest to the output folder. The root output folder will be created
- * if it does not already exist.
- */
-export const copyManifest = ({ manifest, outDir }) => createOutDir(outDir)
- .then(() => fs.copyFile(manifest, path.join(outDir, manifest)));
-/**
- * Create the output directories for each type of asset.
- */
-export const createBuildDirs = (outDir) => Promise.all(Assets.map((asset) => fs.mkdir(path.join(outDir, `${asset}s`), { recursive: true })));
diff --git a/scripts/bin/build/dev.js b/scripts/bin/build/dev.js
deleted file mode 100644
index 6dd38dbde..000000000
--- a/scripts/bin/build/dev.js
+++ /dev/null
@@ -1,338 +0,0 @@
-import chalk from 'chalk';
-import { context as esbuild } from 'esbuild';
-import { buildHtml, buildJsons, initTypedoc, logHtmlResult } from './docs/index.js';
-import { bundleOptions, reduceBundleOutputFiles } from './modules/bundle.js';
-import { reduceTabOutputFiles, tabOptions } from './modules/tab.js';
-import { bundleNameExpander, copyManifest, createBuildCommand, createBuildDirs, divideAndRound, logResult, retrieveBundlesAndTabs, tabNameExpander, } from './buildUtils.js';
-/**
- * Wait until the user presses 'ctrl+c' on the keyboard
- */
-const waitForQuit = () => new Promise((resolve, reject) => {
- process.stdin.setRawMode(true);
- process.stdin.on('data', (data) => {
- const byteArray = [...data];
- if (byteArray.length > 0 && byteArray[0] === 3) {
- console.log('^C');
- process.stdin.setRawMode(false);
- resolve();
- }
- });
- process.stdin.on('error', reject);
-});
-const getBundleContext = ({ srcDir, outDir }, bundles, app) => esbuild({
- ...bundleOptions,
- entryPoints: bundles.map(bundleNameExpander(srcDir)),
- outbase: outDir,
- outdir: outDir,
- plugins: [{
- name: 'Bundle Compiler',
- async setup(pluginBuild) {
- let jsonPromise = null;
- if (app) {
- app.convertAndWatch(async (project) => {
- console.log(chalk.magentaBright('Beginning jsons build...'));
- jsonPromise = buildJsons(project, {
- outDir,
- bundles,
- });
- });
- }
- let startTime;
- pluginBuild.onStart(() => {
- console.log(chalk.magentaBright('Beginning bundles build...'));
- startTime = performance.now();
- });
- pluginBuild.onEnd(async ({ outputFiles }) => {
- const [mainResults, jsonResults] = await Promise.all([
- reduceBundleOutputFiles(outputFiles, startTime, outDir),
- jsonPromise || Promise.resolve([]),
- ]);
- logResult(mainResults.concat(jsonResults), false);
- console.log(chalk.gray(`Bundles took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
- });
- },
- }],
-});
-const getTabContext = ({ srcDir, outDir }, tabs) => esbuild({
- ...tabOptions,
- entryPoints: tabs.map(tabNameExpander(srcDir)),
- outbase: outDir,
- outdir: outDir,
- external: ['react*', 'react-dom'],
- plugins: [{
- name: 'Tab Compiler',
- setup(pluginBuild) {
- let startTime;
- pluginBuild.onStart(() => {
- console.log(chalk.magentaBright('Beginning tabs build...'));
- startTime = performance.now();
- });
- pluginBuild.onEnd(async ({ outputFiles }) => {
- const mainResults = await reduceTabOutputFiles(outputFiles, startTime, outDir);
- logResult(mainResults, false);
- console.log(chalk.gray(`Tabs took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
- });
- },
- }],
-});
-export const watchCommand = createBuildCommand('watch', false)
- .description('Run esbuild in watch mode, rebuilding on every detected file system change')
- .option('--no-docs', 'Don\'t rebuild documentation')
- .action(async (opts) => {
- const [{ bundles, tabs }] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, null, null),
- createBuildDirs(opts.outDir),
- copyManifest(opts),
- ]);
- let app = null;
- if (opts.docs) {
- ({ result: [app] } = await initTypedoc({
- srcDir: opts.srcDir,
- bundles,
- verbose: false,
- }, true));
- }
- const [bundlesContext, tabsContext] = await Promise.all([
- getBundleContext(opts, bundles, app),
- getTabContext(opts, tabs),
- ]);
- console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes\nPress CTRL + C to stop`));
- await Promise.all([bundlesContext.watch(), tabsContext.watch()]);
- await waitForQuit();
- console.log(chalk.yellowBright('Stopping...'));
- const [htmlResult] = await Promise.all([
- opts.docs
- ? buildHtml(app, app.convert(), {
- outDir: opts.outDir,
- modulesSpecified: false,
- })
- : Promise.resolve(null),
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- copyManifest(opts),
- ]);
- logHtmlResult(htmlResult);
-});
-/*
-type DevCommandInputs = {
- docs: boolean;
-
- ip: string | null;
- port: number | null;
-
- watch: boolean;
- serve: boolean;
-} & BuildCommandInputs;
-
-const devCommand = createBuildCommand('dev')
- .description('Use this command to leverage esbuild\'s automatic rebuilding capapbilities.'
- + ' Use --watch to rebuild every time the file system detects changes and'
- + ' --serve to serve modules using a special HTTP server that rebuilds on each request.'
- + ' If neither is specified then --serve is assumed')
- .option('--no-docs', 'Don\'t rebuild documentation')
- .option('-w, --watch', 'Rebuild on file system changes', false)
- .option('-s, --serve', 'Run the HTTP server, and rebuild on every request', false)
- .option('-i, --ip', 'Host interface to bind to', null)
- .option('-p, --port', 'Port to bind for the server to bind to', (value) => {
- const parsedInt = parseInt(value);
- if (isNaN(parsedInt) || parsedInt < 1 || parsedInt > 65535) {
- throw new InvalidArgumentError(`Expected port to be a valid number between 1-65535, got ${value}!`);
- }
- return parsedInt;
- }, null)
- .action(async ({ verbose, ...opts }: DevCommandInputs) => {
- const shouldWatch = opts.watch;
- const shouldServe = opts.serve || !opts.watch;
-
- if (!shouldServe) {
- if (opts.ip) console.log(chalk.yellowBright('--ip option specified without --serve!'));
- if (opts.port) console.log(chalk.yellowBright('--port option specified without --serve!'));
- }
-
- const [{ bundles, tabs }] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, null, null),
- fsPromises.mkdir(`${opts.outDir}/bundles/`, { recursive: true }),
- fsPromises.mkdir(`${opts.outDir}/tabs/`, { recursive: true }),
- fsPromises.mkdir(`${opts.outDir}/jsons/`, { recursive: true }),
- fsPromises.copyFile(opts.manifest, `${opts.outDir}/${opts.manifest}`),
- ]);
-
-
- const [bundlesContext, tabsContext] = await Promise.all([
- getBundleContext(opts, bundles),
- getTabContext(opts, tabs),
- ]);
-
- await Promise.all([
- bundlesContext.watch(),
- tabsContext.watch(),
- ]);
-
- await Promise.all([
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- ]);
-
- await waitForQuit();
-
-
- if (opts.watch) {
- await Promise.all([
- bundlesContext.watch(),
- tabsContext.watch(),
- ]);
- }
-
- let httpServer: http.Server | null = null;
- if (opts.serve) {
- const [bundlesPort, tabsPort] = await Promise.all([
- serveContext(bundlesContext),
- serveContext(tabsContext),
- ]);
-
- httpServer = http.createServer((req, res) => {
- const urlSegments = req.url.split('/');
- if (urlSegments.length === 3) {
- const [, assetType, name] = urlSegments;
-
- if (assetType === 'jsons') {
- const filePath = path.join(opts.outDir, 'jsons', name);
- if (!fsSync.existsSync(filePath)) {
- res.writeHead(404, 'No such json file');
- res.end();
- return;
- }
-
- const readStream = fsSync.createReadStream(filePath);
- readStream.on('data', (data) => res.write(data));
- readStream.on('end', () => {
- res.writeHead(200);
- res.end();
- });
- readStream.on('error', (err) => {
- res.writeHead(500, `Error Occurred: ${err}`);
- res.end();
- });
- } else if (assetType === 'tabs') {
- const proxyReq = http.request({
- host: '127.0.0.2',
- port: tabsPort,
- path: req.url,
- method: req.method,
- headers: req.headers,
- }, (proxyRes) => {
- // Forward each incoming request to esbuild
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res, { end: true });
- });
- // Forward the body of the request to esbuild
- req.pipe(proxyReq, { end: true });
- } else if (assetType === 'bundles') {
- const proxyReq = http.request({
- host: '127.0.0.2',
- port: bundlesPort,
- path: req.url,
- method: req.method,
- headers: req.headers,
- }, (proxyRes) => {
- // Forward each incoming request to esbuild
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res, { end: true });
- });
- // Forward the body of the request to esbuild
- req.pipe(proxyReq, { end: true });
- } else {
- res.writeHead(400);
- res.end();
- }
- }
- });
- httpServer.listen(opts.port, opts.ip);
-
- await new Promise((resolve) => httpServer.once('listening', () => resolve()));
- console.log(`${
- chalk.greenBright(`Serving ${
- chalk.cyanBright(`./${opts.outDir}`)
- } at`)} ${
- chalk.yellowBright(`${opts.ip}:${opts.port}`)
- }`);
- }
-
- await waitForQuit();
-
- if (httpServer) {
- httpServer.close();
- }
-
- await Promise.all([
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- ]);
-
- let app: Application | null = null;
- if (opts.docs) {
- ({ result: [app] } = await initTypedoc({
- srcDir: opts.srcDir,
- bundles: Object.keys(manifest),
- verbose,
- }, true));
- }
-
- let typedocProj: ProjectReflection | null = null;
- const buildDocs = async () => {
- if (!opts.docs) return [];
- typedocProj = app.convert();
- return buildJsons(typedocProj, {
- bundles: Object.keys(manifest),
- outDir: opts.outDir,
- });
- };
-
- if (shouldWatch) {
- console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes`));
- await context.watch();
- }
-
- if (shouldServe) {
- const { port: servePort, host: serveHost } = await context.serve({
- servedir: opts.outDir,
- port: opts.port || 8022,
- host: opts.ip || '0.0.0.0',
- onRequest: ({ method, path: urlPath, remoteAddress, timeInMS }) => console.log(`[${new Date()
- .toISOString()}] ${chalk.gray(remoteAddress)} "${chalk.cyan(`${method} ${urlPath}`)}": Response Time: ${
- chalk.magentaBright(`${divideAndRound(timeInMS, 1000, 2)}s`)}`),
- });
- console.log(`${
- chalk.greenBright(`Serving ${
- chalk.cyanBright(`./${opts.outDir}`)
- } at`)} ${
- chalk.yellowBright(`${serveHost}:${servePort}`)
- }`);
- }
-
- console.log(chalk.yellowBright('Press CTRL + C to stop'));
-
- await waitForQuit();
- console.log(chalk.yellowBright('Stopping...'));
- const [htmlResult] = await Promise.all([
- opts.docs
- ? buildHtml(app, typedocProj, {
- outDir: opts.outDir,
- modulesSpecified: false,
- })
- : Promise.resolve(null),
- context.cancel(),
- ]);
-
- logHtmlResult(htmlResult);
- await context.dispose();
- });
-
-export default devCommand;
-*/
diff --git a/scripts/bin/build/docs/docUtils.js b/scripts/bin/build/docs/docUtils.js
deleted file mode 100644
index 67ec5b570..000000000
--- a/scripts/bin/build/docs/docUtils.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import chalk from 'chalk';
-import { Application, TSConfigReader } from 'typedoc';
-import { wrapWithTimer } from '../../scriptUtils.js';
-import { bundleNameExpander, divideAndRound } from '../buildUtils.js';
-/**
- * Offload running typedoc into async code to increase parallelism
- *
- * @param watch Pass true to initialize typedoc in watch mode. `app.convert()` will not be called.
- */
-export const initTypedoc = wrapWithTimer(({ srcDir, bundles, verbose, }, watch) => new Promise((resolve, reject) => {
- try {
- const app = new Application();
- app.options.addReader(new TSConfigReader());
- app.bootstrap({
- categorizeByGroup: true,
- entryPoints: bundles.map(bundleNameExpander(srcDir)),
- excludeInternal: true,
- logger: watch ? 'none' : undefined,
- logLevel: verbose ? 'Info' : 'Error',
- name: 'Source Academy Modules',
- readme: `${srcDir}/README.md`,
- tsconfig: `${srcDir}/tsconfig.json`,
- skipErrorChecking: true,
- watch,
- });
- if (watch)
- resolve([app, null]);
- const project = app.convert();
- if (!project) {
- reject(new Error('Failed to initialize typedoc - Make sure to check that the source files have no compilation errors!'));
- }
- else
- resolve([app, project]);
- }
- catch (error) {
- reject(error);
- }
-}));
-export const logTypedocTime = (elapsed) => console.log(`${chalk.cyanBright('Took')} ${divideAndRound(elapsed, 1000)}s ${chalk.cyanBright('to initialize typedoc')}`);
diff --git a/scripts/bin/build/docs/drawdown.js b/scripts/bin/build/docs/drawdown.js
deleted file mode 100644
index a6a6c94c0..000000000
--- a/scripts/bin/build/docs/drawdown.js
+++ /dev/null
@@ -1,135 +0,0 @@
-/* eslint-disable*/
-/**
- * Module to convert from markdown into HTML
- * drawdown.js
- * (c) Adam Leggett
- */
-export default (src) => {
- var rx_lt = //g;
- var rx_space = /\t|\r|\uf8ff/g;
- var rx_escape = /\\([\\\|`*_{}\[\]()#+\-~])/g;
- var rx_hr = /^([*\-=_] *){3,}$/gm;
- var rx_blockquote = /\n *> *([^]*?)(?=(\n|$){2})/g;
- var rx_list = /\n( *)(?:[*\-+]|((\d+)|([a-z])|[A-Z])[.)]) +([^]*?)(?=(\n|$){2})/g;
- var rx_listjoin = /<\/(ol|ul)>\n\n<\1>/g;
- var rx_highlight = /(^|[^A-Za-z\d\\])(([*_])|(~)|(\^)|(--)|(\+\+)|`)(\2?)([^<]*?)\2\8(?!\2)(?=\W|_|$)/g;
- var rx_code = /\n((```|~~~).*\n?([^]*?)\n?\2|(( {4}.*?\n)+))/g;
- var rx_link = /((!?)\[(.*?)\]\((.*?)( ".*")?\)|\\([\\`*_{}\[\]()#+\-.!~]))/g;
- var rx_table = /\n(( *\|.*?\| *\n)+)/g;
- var rx_thead = /^.*\n( *\|( *\:?-+\:?-+\:? *\|)* *\n|)/;
- var rx_row = /.*\n/g;
- var rx_cell = /\||(.*?[^\\])\|/g;
- var rx_heading = /(?=^|>|\n)([>\s]*?)(#{1,6}) (.*?)( #*)? *(?=\n|$)/g;
- var rx_para = /(?=^|>|\n)\s*\n+([^<]+?)\n+\s*(?=\n|<|$)/g;
- var rx_stash = /-\d+\uf8ff/g;
- function replace(rex, fn) {
- src = src.replace(rex, fn);
- }
- function element(tag, content) {
- return '<' + tag + '>' + content + '' + tag + '>';
- }
- function blockquote(src) {
- return src.replace(rx_blockquote, function (all, content) {
- return element('blockquote', blockquote(highlight(content.replace(/^ *> */gm, ''))));
- });
- }
- function list(src) {
- return src.replace(rx_list, function (all, ind, ol, num, low, content) {
- var entry = element('li', highlight(content
- .split(RegExp('\n ?' + ind + '(?:(?:\\d+|[a-zA-Z])[.)]|[*\\-+]) +', 'g'))
- .map(list)
- .join('')));
- return ('\n' +
- (ol
- ? ''
- : parseInt(ol, 36) -
- 9 +
- '" style="list-style-type:' +
- (low ? 'low' : 'upp') +
- 'er-alpha">') +
- entry +
- '
'
- : element('ul', entry)));
- });
- }
- function highlight(src) {
- return src.replace(rx_highlight, function (all, _, p1, emp, sub, sup, small, big, p2, content) {
- return (_ +
- element(emp
- ? p2
- ? 'strong'
- : 'em'
- : sub
- ? p2
- ? 's'
- : 'sub'
- : sup
- ? 'sup'
- : small
- ? 'small'
- : big
- ? 'big'
- : 'code', highlight(content)));
- });
- }
- function unesc(str) {
- return str.replace(rx_escape, '$1');
- }
- var stash = [];
- var si = 0;
- src = '\n' + src + '\n';
- replace(rx_lt, '<');
- replace(rx_gt, '>');
- replace(rx_space, ' ');
- // blockquote
- src = blockquote(src);
- // horizontal rule
- replace(rx_hr, '
');
- // list
- src = list(src);
- replace(rx_listjoin, '');
- // code
- replace(rx_code, function (all, p1, p2, p3, p4) {
- stash[--si] = element('pre', element('code', p3 || p4.replace(/^ {4}/gm, '')));
- return si + '\uf8ff';
- });
- // link or image
- replace(rx_link, function (all, p1, p2, p3, p4, p5, p6) {
- stash[--si] = p4
- ? p2
- ? ''
- : '' + unesc(highlight(p3)) + ''
- : p6;
- return si + '\uf8ff';
- });
- // table
- replace(rx_table, function (all, table) {
- var sep = table.match(rx_thead)[1];
- return ('\n' +
- element('table', table.replace(rx_row, function (row, ri) {
- return row == sep
- ? ''
- : element('tr', row.replace(rx_cell, function (all, cell, ci) {
- return ci
- ? element(sep && !ri ? 'th' : 'td', unesc(highlight(cell || '')))
- : '';
- }));
- })));
- });
- // heading
- replace(rx_heading, function (all, _, p1, p2) {
- return _ + element('h' + p1.length, unesc(highlight(p2)));
- });
- // paragraph
- replace(rx_para, function (all, content) {
- return element('p', unesc(highlight(content)));
- });
- // stash
- replace(rx_stash, function (all) {
- return stash[parseInt(all)];
- });
- return src.trim();
-};
diff --git a/scripts/bin/build/docs/html.js b/scripts/bin/build/docs/html.js
deleted file mode 100644
index e5513fd2e..000000000
--- a/scripts/bin/build/docs/html.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { wrapWithTimer } from '../../scriptUtils.js';
-import { divideAndRound, exitOnError, retrieveBundles } from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-/**
- * Build HTML documentation
- */
-export const buildHtml = wrapWithTimer(async (app, project, { outDir, modulesSpecified, }) => {
- if (modulesSpecified) {
- return {
- severity: 'warn',
- };
- }
- try {
- await app.generateDocs(project, `${outDir}/documentation`);
- return {
- severity: 'success',
- };
- }
- catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-});
-/**
- * Log output from `buildHtml`
- * @see {buildHtml}
- */
-export const logHtmlResult = (htmlResult) => {
- if (!htmlResult)
- return;
- const { elapsed, result: { severity, error } } = htmlResult;
- if (severity === 'success') {
- const timeStr = divideAndRound(elapsed, 1000);
- console.log(`${chalk.cyanBright('HTML documentation built')} ${chalk.greenBright('successfully')} in ${timeStr}s\n`);
- }
- else if (severity === 'warn') {
- console.log(chalk.yellowBright('Modules were manually specified, not building HTML documentation\n'));
- }
- else {
- console.log(`${chalk.cyanBright('HTML documentation')} ${chalk.redBright('failed')}: ${error}\n`);
- }
-};
-/**
- * Get CLI command to only build HTML documentation
- */
-const getBuildHtmlCommand = () => new Command('html')
- .option('--outDir ', 'Output directory', 'build')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-v, --verbose', 'Display more information about the build results', false)
- .option('--tsc', 'Run tsc before building')
- .description('Build only HTML documentation')
- .action(async (opts) => {
- const bundles = await retrieveBundles(opts.manifest, null);
- if (opts.tsc) {
- const tscResult = await runTsc(opts.srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error')
- process.exit(1);
- }
- const { elapsed: typedoctime, result: [app, project] } = await initTypedoc({
- bundles,
- srcDir: opts.srcDir,
- verbose: opts.verbose,
- });
- logTypedocTime(typedoctime);
- const htmlResult = await buildHtml(app, project, {
- outDir: opts.outDir,
- modulesSpecified: false,
- });
- logHtmlResult(htmlResult);
- exitOnError([], htmlResult.result);
-});
-export default getBuildHtmlCommand;
diff --git a/scripts/bin/build/docs/index.js b/scripts/bin/build/docs/index.js
deleted file mode 100644
index 2d27311c2..000000000
--- a/scripts/bin/build/docs/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import chalk from 'chalk';
-import { printList } from '../../scriptUtils.js';
-import { createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundles } from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-import { buildHtml, logHtmlResult } from './html.js';
-import { buildJsons } from './json.js';
-export const getBuildDocsCommand = () => createBuildCommand('docs', true)
- .argument('[modules...]', 'Manually specify which modules to build documentation', null)
- .action(async (modules, { manifest, srcDir, outDir, verbose, tsc }) => {
- const [bundles] = await Promise.all([
- retrieveBundles(manifest, modules),
- createOutDir(outDir),
- ]);
- if (bundles.length === 0)
- return;
- if (tsc) {
- const tscResult = await runTsc(srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error')
- process.exit(1);
- }
- printList(`${chalk.cyanBright('Building HTML documentation and jsons for the following bundles:')}\n`, bundles);
- const { elapsed, result: [app, project] } = await initTypedoc({
- bundles,
- srcDir,
- verbose,
- });
- const [jsonResults, htmlResult] = await Promise.all([
- buildJsons(project, {
- outDir,
- bundles,
- }),
- buildHtml(app, project, {
- outDir,
- modulesSpecified: modules !== null,
- }),
- // app.generateJson(project, `${buildOpts.outDir}/docs.json`),
- ]);
- logTypedocTime(elapsed);
- if (!jsonResults && !htmlResult)
- return;
- logHtmlResult(htmlResult);
- logResult(jsonResults, verbose);
- exitOnError(jsonResults, htmlResult.result);
-})
- .description('Build only jsons and HTML documentation');
-export default getBuildDocsCommand;
-export { default as getBuildHtmlCommand, logHtmlResult, buildHtml } from './html.js';
-export { default as getBuildJsonCommand, buildJsons } from './json.js';
-export { initTypedoc } from './docUtils.js';
diff --git a/scripts/bin/build/docs/json.js b/scripts/bin/build/docs/json.js
deleted file mode 100644
index c2e55bdf4..000000000
--- a/scripts/bin/build/docs/json.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import chalk from 'chalk';
-import fs from 'fs/promises';
-import { printList, wrapWithTimer } from '../../scriptUtils.js';
-import { createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundles, } from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-import drawdown from './drawdown.js';
-const typeToName = (type, alt = 'unknown') => (type ? type.name : alt);
-/**
- * Parsers to convert typedoc elements into strings
- */
-export const parsers = {
- Variable(element) {
- let desc;
- if (!element.comment)
- desc = 'No description available';
- else {
- desc = element.comment.summary.map(({ text }) => text)
- .join('');
- }
- return {
- header: `${element.name}: ${typeToName(element.type)}`,
- desc: drawdown(desc),
- };
- },
- Function({ name: elementName, signatures: [signature] }) {
- // Form the parameter string for the function
- let paramStr;
- if (!signature.parameters)
- paramStr = '()';
- else {
- paramStr = `(${signature.parameters
- .map(({ type, name }) => {
- const typeStr = typeToName(type);
- return `${name}: ${typeStr}`;
- })
- .join(', ')})`;
- }
- const resultStr = typeToName(signature.type, 'void');
- let desc;
- if (!signature.comment)
- desc = 'No description available';
- else {
- desc = signature.comment.summary.map(({ text }) => text)
- .join('');
- }
- return {
- header: `${elementName}${paramStr} → {${resultStr}}`,
- desc: drawdown(desc),
- };
- },
-};
-/**
- * Build a single json
- */
-const buildJson = wrapWithTimer(async (bundle, moduleDocs, outDir) => {
- try {
- if (!moduleDocs) {
- return {
- severity: 'error',
- error: `Could not find generated docs for ${bundle}`,
- };
- }
- const [sevRes, result] = moduleDocs.children.reduce(([{ severity, errors }, decls], decl) => {
- try {
- const parser = parsers[decl.kindString];
- if (!parser) {
- return [{
- severity: 'warn',
- errors: [...errors, `Symbol '${decl.name}': Could not find parser for type ${decl.kindString}`],
- }, decls];
- }
- const { header, desc } = parser(decl);
- return [{
- severity,
- errors,
- }, {
- ...decls,
- [decl.name]: ``,
- }];
- }
- catch (error) {
- return [{
- severity: 'warn',
- errors: [...errors, `Could not parse declaration for ${decl.name}: ${error}`],
- }];
- }
- }, [
- {
- severity: 'success',
- errors: [],
- },
- {},
- ]);
- let size;
- if (result) {
- const outFile = `${outDir}/jsons/${bundle}.json`;
- await fs.writeFile(outFile, JSON.stringify(result, null, 2));
- ({ size } = await fs.stat(outFile));
- }
- else {
- if (sevRes.severity !== 'error')
- sevRes.severity = 'warn';
- sevRes.errors.push(`No json generated for ${bundle}`);
- }
- const errorStr = sevRes.errors.length > 1 ? `${sevRes.errors[0]} +${sevRes.errors.length - 1}` : sevRes.errors[0];
- return {
- severity: sevRes.severity,
- fileSize: size,
- error: errorStr,
- };
- }
- catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-});
-/**
- * Build all specified jsons
- */
-export const buildJsons = async (project, { outDir, bundles }) => {
- await fs.mkdir(`${outDir}/jsons`, { recursive: true });
- if (bundles.length === 1) {
- // If only 1 bundle is provided, typedoc's output is different in structure
- // So this new parser is used instead.
- const [bundle] = bundles;
- const { elapsed, result } = await buildJson(bundle, project, outDir);
- return [['json', bundle, {
- ...result,
- elapsed,
- }]];
- }
- return Promise.all(bundles.map(async (bundle) => {
- const { elapsed, result } = await buildJson(bundle, project.getChildByName(bundle), outDir);
- return ['json', bundle, {
- ...result,
- elapsed,
- }];
- }));
-};
-/**
- * Get console command for building jsons
- *
- */
-const getJsonCommand = () => createBuildCommand('jsons', false)
- .option('--tsc', 'Run tsc before building')
- .argument('[modules...]', 'Manually specify which modules to build jsons for', null)
- .action(async (modules, { manifest, srcDir, outDir, verbose, tsc }) => {
- const [bundles] = await Promise.all([
- retrieveBundles(manifest, modules),
- createOutDir(outDir),
- ]);
- if (bundles.length === 0)
- return;
- if (tsc) {
- const tscResult = await runTsc(srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error')
- process.exit(1);
- }
- const { elapsed: typedocTime, result: [, project] } = await initTypedoc({
- bundles,
- srcDir,
- verbose,
- });
- logTypedocTime(typedocTime);
- printList(chalk.magentaBright('Building jsons for the following modules:\n'), bundles);
- const jsonResults = await buildJsons(project, {
- bundles,
- outDir,
- });
- logResult(jsonResults, verbose);
- exitOnError(jsonResults);
-})
- .description('Build only jsons');
-export default getJsonCommand;
diff --git a/scripts/bin/build/index.js b/scripts/bin/build/index.js
deleted file mode 100644
index 04c9e6df5..000000000
--- a/scripts/bin/build/index.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { printList } from '../scriptUtils.js';
-import { logTypedocTime } from './docs/docUtils.js';
-import getBuildDocsCommand, { buildHtml, buildJsons, getBuildHtmlCommand, getBuildJsonCommand, initTypedoc, logHtmlResult, } from './docs/index.js';
-import getBuildModulesCommand, { buildModules, getBuildTabsCommand, } from './modules/index.js';
-import { prebuild } from './prebuild/index.js';
-import { copyManifest, createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundlesAndTabs } from './buildUtils.js';
-export const getBuildAllCommand = () => createBuildCommand('all', true)
- .argument('[modules...]', 'Manually specify which modules to build', null)
- .action(async (modules, opts) => {
- const [assets] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, modules, null),
- createOutDir(opts.outDir),
- ]);
- await prebuild(opts, assets);
- printList(`${chalk.cyanBright('Building bundles, tabs, jsons and HTML for the following bundles:')}\n`, assets.bundles);
- const [results, { typedoctime, html: htmlResult, json: jsonResults, }] = await Promise.all([
- buildModules(opts, assets),
- initTypedoc({
- ...opts,
- bundles: assets.bundles,
- })
- .then(async ({ elapsed, result: [app, project] }) => {
- const [json, html] = await Promise.all([
- buildJsons(project, {
- outDir: opts.outDir,
- bundles: assets.bundles,
- }),
- buildHtml(app, project, {
- outDir: opts.outDir,
- modulesSpecified: modules !== null,
- }),
- ]);
- return {
- json,
- html,
- typedoctime: elapsed,
- };
- }),
- copyManifest(opts),
- ]);
- logTypedocTime(typedoctime);
- logResult(results.concat(jsonResults), opts.verbose);
- logHtmlResult(htmlResult);
- exitOnError(results, ...jsonResults, htmlResult.result);
-})
- .description('Build bundles, tabs, jsons and HTML documentation');
-export default new Command('build')
- .description('Run without arguments to build all, or use a specific build subcommand')
- .addCommand(getBuildAllCommand(), { isDefault: true })
- .addCommand(getBuildDocsCommand())
- .addCommand(getBuildHtmlCommand())
- .addCommand(getBuildJsonCommand())
- .addCommand(getBuildModulesCommand())
- .addCommand(getBuildTabsCommand());
diff --git a/scripts/bin/build/modules/bundle.js b/scripts/bin/build/modules/bundle.js
deleted file mode 100644
index 1d022a8fb..000000000
--- a/scripts/bin/build/modules/bundle.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import { parse } from 'acorn';
-import { generate } from 'astring';
-import { build as esbuild, } from 'esbuild';
-import fs from 'fs/promises';
-import pathlib from 'path';
-import { bundleNameExpander } from '../buildUtils.js';
-import { esbuildOptions } from './moduleUtils.js';
-export const outputBundle = async (name, bundleText, outDir) => {
- try {
- const parsed = parse(bundleText, { ecmaVersion: 6 });
- // Account for 'use strict'; directives
- let declStatement;
- if (parsed.body[0].type === 'VariableDeclaration') {
- declStatement = parsed.body[0];
- }
- else {
- declStatement = parsed.body[1];
- }
- const varDeclarator = declStatement.declarations[0];
- const callExpression = varDeclarator.init;
- const moduleCode = callExpression.callee;
- const output = {
- type: 'ArrowFunctionExpression',
- body: {
- type: 'BlockStatement',
- body: moduleCode.body.type === 'BlockStatement'
- ? moduleCode.body.body
- : [{
- type: 'ExpressionStatement',
- expression: moduleCode.body,
- }],
- },
- params: [
- {
- type: 'Identifier',
- name: 'require',
- },
- ],
- };
- let newCode = generate(output);
- if (newCode.endsWith(';'))
- newCode = newCode.slice(0, -1);
- const outFile = `${outDir}/bundles/${name}.js`;
- await fs.writeFile(outFile, newCode);
- const { size } = await fs.stat(outFile);
- return {
- severity: 'success',
- fileSize: size,
- };
- }
- catch (error) {
- console.log(error);
- return {
- severity: 'error',
- error,
- };
- }
-};
-export const bundleOptions = {
- ...esbuildOptions,
- external: ['js-slang*'],
-};
-export const buildBundles = async (bundles, { srcDir, outDir }) => {
- const nameExpander = bundleNameExpander(srcDir);
- const { outputFiles } = await esbuild({
- ...bundleOptions,
- entryPoints: bundles.map(nameExpander),
- outbase: outDir,
- outdir: outDir,
- });
- return outputFiles;
-};
-export const reduceBundleOutputFiles = (outputFiles, startTime, outDir) => Promise.all(outputFiles.map(async ({ path, text }) => {
- const [rawType, name] = path.split(pathlib.sep)
- .slice(-3, -1);
- if (rawType !== 'bundles') {
- throw new Error(`Expected only bundles, got ${rawType}`);
- }
- const result = await outputBundle(name, text, outDir);
- return ['bundle', name, {
- elapsed: performance.now() - startTime,
- ...result,
- }];
-}));
diff --git a/scripts/bin/build/modules/index.js b/scripts/bin/build/modules/index.js
deleted file mode 100644
index 5b2012647..000000000
--- a/scripts/bin/build/modules/index.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import chalk from 'chalk';
-import { promises as fs } from 'fs';
-import { printList } from '../../scriptUtils.js';
-import { copyManifest, createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundlesAndTabs, } from '../buildUtils.js';
-import { prebuild } from '../prebuild/index.js';
-import { buildBundles, reduceBundleOutputFiles } from './bundle.js';
-import { buildTabs, reduceTabOutputFiles } from './tab.js';
-export const buildModules = async (opts, { bundles, tabs }) => {
- const startPromises = [];
- if (bundles.length > 0) {
- startPromises.push(fs.mkdir(`${opts.outDir}/bundles`, { recursive: true }));
- }
- if (tabs.length > 0) {
- startPromises.push(fs.mkdir(`${opts.outDir}/tabs`, { recursive: true }));
- }
- await Promise.all(startPromises);
- const startTime = performance.now();
- const [bundleResults, tabResults] = await Promise.all([
- buildBundles(bundles, opts)
- .then((outputFiles) => reduceBundleOutputFiles(outputFiles, startTime, opts.outDir)),
- buildTabs(tabs, opts)
- .then((outputFiles) => reduceTabOutputFiles(outputFiles, startTime, opts.outDir)),
- ]);
- return bundleResults.concat(tabResults);
-};
-const getBuildModulesCommand = () => createBuildCommand('modules', true)
- .argument('[modules...]', 'Manually specify which modules to build', null)
- .description('Build modules and their tabs')
- .action(async (modules, { manifest, ...opts }) => {
- const [assets] = await Promise.all([
- retrieveBundlesAndTabs(manifest, modules, null),
- createOutDir(opts.outDir),
- ]);
- await prebuild(opts, assets);
- printList(`${chalk.magentaBright('Building bundles and tabs for the following bundles:')}\n`, assets.bundles);
- const [results] = await Promise.all([
- buildModules(opts, assets),
- copyManifest({
- manifest,
- outDir: opts.outDir,
- }),
- ]);
- logResult(results, opts.verbose);
- exitOnError(results);
-})
- .description('Build only bundles and tabs');
-export { default as getBuildTabsCommand } from './tab.js';
-export default getBuildModulesCommand;
diff --git a/scripts/bin/build/modules/moduleUtils.js b/scripts/bin/build/modules/moduleUtils.js
deleted file mode 100644
index 5b7dfaa35..000000000
--- a/scripts/bin/build/modules/moduleUtils.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Build the AST representation of a `require` function to use with the transpiled IIFEs
- */
-export const requireCreator = (createObj) => ({
- type: 'FunctionDeclaration',
- id: {
- type: 'Identifier',
- name: 'require',
- },
- params: [
- {
- type: 'Identifier',
- name: 'x',
- },
- ],
- body: {
- type: 'BlockStatement',
- body: [
- {
- type: 'VariableDeclaration',
- kind: 'const',
- declarations: [
- {
- type: 'VariableDeclarator',
- id: {
- type: 'Identifier',
- name: 'result',
- },
- init: {
- type: 'MemberExpression',
- computed: true,
- property: {
- type: 'Identifier',
- name: 'x',
- },
- object: {
- type: 'ObjectExpression',
- properties: Object.entries(createObj)
- .map(([key, value]) => ({
- type: 'Property',
- kind: 'init',
- key: {
- type: 'Literal',
- value: key,
- },
- value: {
- type: 'Identifier',
- name: value,
- },
- })),
- },
- },
- },
- ],
- },
- {
- type: 'IfStatement',
- test: {
- type: 'BinaryExpression',
- left: {
- type: 'Identifier',
- name: 'result',
- },
- operator: '===',
- right: {
- type: 'Identifier',
- name: 'undefined',
- },
- },
- consequent: {
- type: 'ThrowStatement',
- argument: {
- type: 'NewExpression',
- callee: {
- type: 'Identifier',
- name: 'Error',
- },
- arguments: [
- {
- type: 'TemplateLiteral',
- expressions: [
- {
- type: 'Identifier',
- name: 'x',
- },
- ],
- quasis: [
- {
- type: 'TemplateElement',
- value: {
- raw: 'Internal Error: Unknown import "',
- },
- tail: false,
- },
- {
- type: 'TemplateElement',
- value: {
- raw: '"!',
- },
- tail: true,
- },
- ],
- },
- ],
- },
- },
- alternate: {
- type: 'ReturnStatement',
- argument: {
- type: 'Identifier',
- name: 'result',
- },
- },
- },
- ],
- },
-});
-export const esbuildOptions = {
- bundle: true,
- format: 'iife',
- globalName: 'module',
- define: {
- process: JSON.stringify({
- env: {
- NODE_ENV: 'production',
- },
- }),
- },
- loader: {
- '.ts': 'ts',
- '.tsx': 'tsx',
- },
- // minify: true,
- platform: 'browser',
- target: 'es6',
- write: false,
-};
diff --git a/scripts/bin/build/modules/tab.js b/scripts/bin/build/modules/tab.js
deleted file mode 100644
index 82f47b922..000000000
--- a/scripts/bin/build/modules/tab.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { parse } from 'acorn';
-import { generate } from 'astring';
-import chalk from 'chalk';
-import { build as esbuild, } from 'esbuild';
-import fs from 'fs/promises';
-import pathlib from 'path';
-import { printList } from '../../scriptUtils.js';
-import { copyManifest, createBuildCommand, exitOnError, logResult, retrieveTabs, tabNameExpander, } from '../buildUtils.js';
-import { prebuild } from '../prebuild/index.js';
-import { esbuildOptions } from './moduleUtils.js';
-const outputTab = async (tabName, text, outDir) => {
- try {
- const parsed = parse(text, { ecmaVersion: 6 });
- const declStatement = parsed.body[1];
- const newTab = {
- type: 'ArrowFunctionExpression',
- body: {
- type: 'MemberExpression',
- object: declStatement.declarations[0].init,
- property: {
- type: 'Literal',
- value: 'default',
- },
- computed: true,
- },
- params: [{
- type: 'Identifier',
- name: 'require',
- }],
- };
- let newCode = generate(newTab);
- if (newCode.endsWith(';'))
- newCode = newCode.slice(0, -1);
- const outFile = `${outDir}/tabs/${tabName}.js`;
- await fs.writeFile(outFile, newCode);
- const { size } = await fs.stat(outFile);
- return {
- severity: 'success',
- fileSize: size,
- };
- }
- catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-};
-export const tabOptions = {
- ...esbuildOptions,
- jsx: 'automatic',
- external: ['react', 'react-dom', 'react/jsx-runtime'],
-};
-export const buildTabs = async (tabs, { srcDir, outDir }) => {
- const nameExpander = tabNameExpander(srcDir);
- const { outputFiles } = await esbuild({
- ...tabOptions,
- entryPoints: tabs.map(nameExpander),
- outbase: outDir,
- outdir: outDir,
- });
- return outputFiles;
-};
-export const reduceTabOutputFiles = (outputFiles, startTime, outDir) => Promise.all(outputFiles.map(async ({ path, text }) => {
- const [rawType, name] = path.split(pathlib.sep)
- .slice(-3, -1);
- if (rawType !== 'tabs') {
- throw new Error(`Expected only tabs, got ${rawType}`);
- }
- const result = await outputTab(name, text, outDir);
- return ['tab', name, {
- elapsed: performance.now() - startTime,
- ...result,
- }];
-}));
-const getBuildTabsCommand = () => createBuildCommand('tabs', true)
- .argument('[tabs...]', 'Manually specify which tabs to build', null)
- .description('Build only tabs')
- .action(async (tabsOpt, { manifest, ...opts }) => {
- const tabs = await retrieveTabs(manifest, tabsOpt);
- await prebuild(opts, {
- tabs,
- bundles: [],
- });
- printList(`${chalk.magentaBright('Building the following tabs:')}\n`, tabs);
- const startTime = performance.now();
- const [reducedRes] = await Promise.all([
- buildTabs(tabs, opts)
- .then((results) => reduceTabOutputFiles(results, startTime, opts.outDir)),
- copyManifest({
- outDir: opts.outDir,
- manifest,
- }),
- ]);
- logResult(reducedRes, opts.verbose);
- exitOnError(reducedRes);
-});
-export default getBuildTabsCommand;
diff --git a/scripts/bin/build/prebuild/eslint.js b/scripts/bin/build/prebuild/eslint.js
deleted file mode 100644
index 90fcbd481..000000000
--- a/scripts/bin/build/prebuild/eslint.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { ESLint } from 'eslint';
-import pathlib from 'path';
-import { findSeverity, printList, wrapWithTimer } from '../../scriptUtils.js';
-import { divideAndRound, exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
-/**
- * Run eslint programmatically
- * Refer to https://eslint.org/docs/latest/integrate/nodejs-api for documentation
- */
-export const runEslint = wrapWithTimer(async (opts, { bundles, tabs }) => {
- const linter = new ESLint({
- cwd: pathlib.resolve(opts.srcDir),
- overrideConfigFile: '.eslintrc.cjs',
- extensions: ['ts', 'tsx'],
- fix: opts.fix,
- useEslintrc: false,
- });
- const promises = [
- bundles.length > 0 ? linter.lintFiles(bundles.map((bundle) => pathlib.join('bundles', bundle))) : Promise.resolve([]),
- tabs.length > 0 ? linter.lintFiles(tabs.map((tabName) => pathlib.join('tabs', tabName))) : Promise.resolve([]),
- ];
- if (bundles.length > 0) {
- printList(`${chalk.magentaBright('Running eslint on the following bundles')}:\n`, bundles);
- }
- if (tabs.length > 0) {
- printList(`${chalk.magentaBright('Running eslint on the following tabs')}:\n`, tabs);
- }
- const [lintBundles, lintTabs] = await Promise.all(promises);
- const lintResults = [...lintBundles, ...lintTabs];
- if (opts.fix) {
- console.log(chalk.magentaBright('Running eslint autofix...'));
- await ESLint.outputFixes(lintResults);
- }
- const lintSeverity = findSeverity(lintResults, ({ errorCount, warningCount }) => {
- if (errorCount > 0)
- return 'error';
- if (warningCount > 0)
- return 'warn';
- return 'success';
- });
- const outputFormatter = await linter.loadFormatter('stylish');
- const formatterOutput = outputFormatter.format(lintResults);
- return {
- formatted: typeof formatterOutput === 'string' ? formatterOutput : await formatterOutput,
- results: lintResults,
- severity: lintSeverity,
- };
-});
-export const logLintResult = (input) => {
- if (!input)
- return;
- const { elapsed, result: { formatted, severity } } = input;
- let errStr;
- if (severity === 'error')
- errStr = chalk.cyanBright('with ') + chalk.redBright('errors');
- else if (severity === 'warn')
- errStr = chalk.cyanBright('with ') + chalk.yellowBright('warnings');
- else
- errStr = chalk.greenBright('successfully');
- console.log(`${chalk.cyanBright(`Linting completed in ${divideAndRound(elapsed, 1000)}s ${errStr}:`)}\n${formatted}`);
-};
-const getLintCommand = () => new Command('lint')
- .description('Run eslint')
- .option('--fix', 'Ask eslint to autofix linting errors', false)
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules ', 'Manually specify which modules to check', null)
- .option('-t, --tabs ', 'Manually specify which tabs to check', null)
- .option('-v, --verbose', 'Display more information about the build results', false)
- .action(async ({ modules, tabs, manifest, ...opts }) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs);
- const result = await runEslint(opts, assets);
- logLintResult(result);
- exitOnError([], result.result);
-});
-export default getLintCommand;
diff --git a/scripts/bin/build/prebuild/index.js b/scripts/bin/build/prebuild/index.js
deleted file mode 100644
index b9aad0bc1..000000000
--- a/scripts/bin/build/prebuild/index.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Command } from 'commander';
-import { exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
-import { logLintResult, runEslint } from './eslint.js';
-import { logTscResults, runTsc } from './tsc.js';
-/**
- * Run both `tsc` and `eslint` in parallel if `--fix` was not specified. Otherwise, run eslint
- * to fix linting errors first, then run tsc for type checking
- *
- * @returns An object that contains the results from linting and typechecking
- */
-const prebuildInternal = async (opts, assets) => {
- if (opts.fix) {
- // Run tsc and then lint
- const lintResult = await runEslint(opts, assets);
- if (!opts.tsc || lintResult.result.severity === 'error') {
- return {
- lintResult,
- tscResult: null,
- };
- }
- const tscResult = await runTsc(opts.srcDir, assets);
- return {
- lintResult,
- tscResult,
- };
- // eslint-disable-next-line no-else-return
- }
- else {
- const [lintResult, tscResult] = await Promise.all([
- opts.lint ? runEslint(opts, assets) : Promise.resolve(null),
- opts.tsc ? runTsc(opts.srcDir, assets) : Promise.resolve(null),
- ]);
- return {
- lintResult,
- tscResult,
- };
- }
-};
-/**
- * Run eslint and tsc based on the provided options, and exit with code 1
- * if either returns with an error status
- */
-export const prebuild = async (opts, assets) => {
- const { lintResult, tscResult } = await prebuildInternal(opts, assets);
- logLintResult(lintResult);
- logTscResults(tscResult);
- exitOnError([], lintResult?.result, tscResult?.result);
- if (lintResult?.result.severity === 'error' || tscResult?.result.severity === 'error') {
- throw new Error('Exiting for jest');
- }
-};
-const getPrebuildCommand = () => new Command('prebuild')
- .description('Run both tsc and eslint')
- .option('--fix', 'Ask eslint to autofix linting errors', false)
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
- .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
- .action(async ({ modules, tabs, manifest, ...opts }) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs, false);
- await prebuild({
- ...opts,
- tsc: true,
- lint: true,
- }, assets);
-});
-export default getPrebuildCommand;
-export { default as getLintCommand } from './eslint.js';
-export { default as getTscCommand } from './tsc.js';
diff --git a/scripts/bin/build/prebuild/tsc.js b/scripts/bin/build/prebuild/tsc.js
deleted file mode 100644
index 1cf913945..000000000
--- a/scripts/bin/build/prebuild/tsc.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { existsSync, promises as fs } from 'fs';
-import pathlib from 'path';
-import ts from 'typescript';
-import { printList, wrapWithTimer } from '../../scriptUtils.js';
-import { bundleNameExpander, divideAndRound, exitOnError, retrieveBundlesAndTabs, tabNameExpander } from '../buildUtils.js';
-const getTsconfig = async (srcDir) => {
- // Step 1: Read the text from tsconfig.json
- const tsconfigLocation = pathlib.join(srcDir, 'tsconfig.json');
- if (!existsSync(tsconfigLocation)) {
- return {
- severity: 'error',
- results: [],
- error: `Could not locate tsconfig.json at ${tsconfigLocation}`,
- };
- }
- const configText = await fs.readFile(tsconfigLocation, 'utf-8');
- // Step 2: Parse the raw text into a json object
- const { error: configJsonError, config: configJson } = ts.parseConfigFileTextToJson(tsconfigLocation, configText);
- if (configJsonError) {
- return {
- severity: 'error',
- results: [configJsonError],
- };
- }
- // Step 3: Parse the json object into a config object for use by tsc
- const { errors: parseErrors, options: tsconfig } = ts.parseJsonConfigFileContent(configJson, ts.sys, srcDir);
- if (parseErrors.length > 0) {
- return {
- severity: 'error',
- results: parseErrors,
- };
- }
- return {
- severity: 'success',
- results: tsconfig,
- };
-};
-/**
- * @param params_0 Source Directory
- */
-export const runTsc = wrapWithTimer((async (srcDir, { bundles, tabs }) => {
- const fileNames = [];
- if (bundles.length > 0) {
- printList(`${chalk.magentaBright('Running tsc on the following bundles')}:\n`, bundles);
- bundles.forEach((bundle) => fileNames.push(bundleNameExpander(srcDir)(bundle)));
- }
- if (tabs.length > 0) {
- printList(`${chalk.magentaBright('Running tsc on the following tabs')}:\n`, tabs);
- tabs.forEach((tabName) => fileNames.push(tabNameExpander(srcDir)(tabName)));
- }
- const tsconfigRes = await getTsconfig(srcDir);
- if (tsconfigRes.severity === 'error') {
- return {
- severity: 'error',
- results: tsconfigRes.results,
- };
- }
- const tsc = ts.createProgram(fileNames, tsconfigRes.results);
- const results = tsc.emit();
- const diagnostics = ts.getPreEmitDiagnostics(tsc)
- .concat(results.diagnostics);
- return {
- severity: diagnostics.length > 0 ? 'error' : 'success',
- results: diagnostics,
- };
-}));
-export const logTscResults = (input) => {
- if (!input)
- return;
- const { elapsed, result: { severity, results, error } } = input;
- if (error) {
- console.log(`${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')}:`)} ${error}`);
- return;
- }
- const diagStr = ts.formatDiagnosticsWithColorAndContext(results, {
- getNewLine: () => '\n',
- getCurrentDirectory: () => pathlib.resolve('.'),
- getCanonicalFileName: (name) => pathlib.basename(name),
- });
- if (severity === 'error') {
- console.log(`${diagStr}\n${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')} in ${divideAndRound(elapsed, 1000)}s`)}`);
- }
- else {
- console.log(`${diagStr}\n${chalk.cyanBright(`tsc completed ${chalk.greenBright('successfully')} in ${divideAndRound(elapsed, 1000)}s`)}`);
- }
-};
-const getTscCommand = () => new Command('typecheck')
- .description('Run tsc to perform type checking')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
- .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
- .option('-v, --verbose', 'Display more information about the build results', false)
- .action(async ({ modules, tabs, manifest, srcDir }) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs);
- const tscResults = await runTsc(srcDir, assets);
- logTscResults(tscResults);
- exitOnError([], tscResults.result);
-});
-export default getTscCommand;
diff --git a/scripts/bin/build/types.js b/scripts/bin/build/types.js
deleted file mode 100644
index f343dc9ae..000000000
--- a/scripts/bin/build/types.js
+++ /dev/null
@@ -1 +0,0 @@
-export const Assets = ['bundle', 'tab', 'json'];
diff --git a/scripts/bin/index.js b/scripts/bin/index.js
deleted file mode 100644
index f92e63934..000000000
--- a/scripts/bin/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Command } from 'commander';
-import { watchCommand } from './build/dev.js';
-import buildAllCommand from './build/index.js';
-import getPrebuildCommand, { getLintCommand, getTscCommand } from './build/prebuild/index.js';
-import createCommand from './templates/index.js';
-const parser = new Command()
- .addCommand(buildAllCommand)
- .addCommand(createCommand)
- .addCommand(getLintCommand())
- .addCommand(getPrebuildCommand())
- .addCommand(getTscCommand())
- .addCommand(watchCommand);
-await parser.parseAsync();
-process.exit();
diff --git a/scripts/bin/scriptUtils.js b/scripts/bin/scriptUtils.js
deleted file mode 100644
index 68f2de89c..000000000
--- a/scripts/bin/scriptUtils.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { readFile } from 'fs/promises';
-import { dirname, join } from 'path';
-import { fileURLToPath } from 'url';
-export function cjsDirname(url) {
- return join(dirname(fileURLToPath(url)));
-}
-export const retrieveManifest = async (manifest) => {
- try {
- const rawManifest = await readFile(manifest, 'utf-8');
- return JSON.parse(rawManifest);
- }
- catch (error) {
- if (error.code === 'ENOENT')
- throw new Error(`Could not locate manifest file at ${manifest}`);
- throw error;
- }
-};
-export const wrapWithTimer = (func) => async (...params) => {
- const startTime = performance.now();
- const result = await func(...params);
- const endTime = performance.now();
- return {
- elapsed: endTime - startTime,
- result,
- };
-};
-export const printList = (header, lst, mapper, sep = '\n') => {
- const mappingFunction = mapper || ((each) => {
- if (typeof each === 'string')
- return each;
- return `${each}`;
- });
- console.log(`${header}\n${lst.map((str, i) => `${i + 1}. ${mappingFunction(str)}`)
- .join(sep)}`);
-};
-export const findSeverity = (items, converter) => {
- let output = 'success';
- for (const item of items) {
- const severity = converter(item);
- if (severity === 'error')
- return 'error';
- if (severity === 'warn')
- output = 'warn';
- }
- return output;
-};
diff --git a/scripts/bin/templates/index.js b/scripts/bin/templates/index.js
deleted file mode 100644
index d83760495..000000000
--- a/scripts/bin/templates/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Command } from 'commander';
-import { addNew as addNewModule } from './module.js';
-import { askQuestion, error as _error, info, rl, warn } from './print.js';
-import { addNew as addNewTab } from './tab.js';
-async function askMode() {
- while (true) {
- // eslint-disable-next-line no-await-in-loop
- const mode = await askQuestion('What would you like to create? (module/tab)');
- if (mode !== 'module' && mode !== 'tab') {
- warn("Please answer with only 'module' or 'tab'.");
- }
- else {
- return mode;
- }
- }
-}
-export default new Command('create')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .description('Interactively create a new module or tab')
- .action(async (buildOpts) => {
- try {
- const mode = await askMode();
- if (mode === 'module')
- await addNewModule(buildOpts);
- else if (mode === 'tab')
- await addNewTab(buildOpts);
- }
- catch (error) {
- _error(`ERROR: ${error.message}`);
- info('Terminating module app...');
- }
- finally {
- rl.close();
- }
-});
diff --git a/scripts/bin/templates/module.js b/scripts/bin/templates/module.js
deleted file mode 100644
index 22b4bd22e..000000000
--- a/scripts/bin/templates/module.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { promises as fs } from 'fs';
-import { cjsDirname, retrieveManifest } from '../scriptUtils.js';
-import { askQuestion, success, warn } from './print.js';
-import { isSnakeCase } from './utilities.js';
-export const check = (manifest, name) => Object.keys(manifest)
- .includes(name);
-async function askModuleName(manifest) {
- while (true) {
- // eslint-disable-next-line no-await-in-loop
- const name = await askQuestion('What is the name of your new module? (eg. binary_tree)');
- if (isSnakeCase(name) === false) {
- warn('Module names must be in snake case. (eg. binary_tree)');
- }
- else if (check(manifest, name)) {
- warn('A module with the same name already exists.');
- }
- else {
- return name;
- }
- }
-}
-export async function addNew(buildOpts) {
- const manifest = await retrieveManifest(buildOpts.manifest);
- const moduleName = await askModuleName(manifest);
- const bundleDestination = `${buildOpts.srcDir}/bundles/${moduleName}`;
- await fs.mkdir(bundleDestination, { recursive: true });
- await fs.copyFile(`${cjsDirname(import.meta.url)}/templates/__bundle__.ts`, `${bundleDestination}/index.ts`);
- await fs.writeFile('modules.json', JSON.stringify({
- ...manifest,
- [moduleName]: { tabs: [] },
- }, null, 2));
- success(`Bundle for module ${moduleName} created at ${bundleDestination}.`);
-}
diff --git a/scripts/bin/templates/print.js b/scripts/bin/templates/print.js
deleted file mode 100644
index 0659a5b58..000000000
--- a/scripts/bin/templates/print.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import chalk from 'chalk';
-import { createInterface } from 'readline';
-export const rl = createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-export function info(...args) {
- return console.log(...args.map((string) => chalk.grey(string)));
-}
-export function error(...args) {
- return console.log(...args.map((string) => chalk.red(string)));
-}
-export function warn(...args) {
- return console.log(...args.map((string) => chalk.yellow(string)));
-}
-export function success(...args) {
- return console.log(...args.map((string) => chalk.green(string)));
-}
-export function askQuestion(question) {
- return new Promise((resolve) => {
- rl.question(chalk.blueBright(`${question}\n`), resolve);
- });
-}
diff --git a/scripts/bin/templates/tab.js b/scripts/bin/templates/tab.js
deleted file mode 100644
index 90e4ad3ab..000000000
--- a/scripts/bin/templates/tab.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/* eslint-disable no-await-in-loop */
-import { promises as fs } from 'fs';
-import { cjsDirname, retrieveManifest } from '../scriptUtils.js';
-import { check as _check } from './module.js';
-import { askQuestion, success, warn } from './print.js';
-import { isPascalCase } from './utilities.js';
-export function check(manifest, tabName) {
- return Object.values(manifest)
- .flatMap((x) => x.tabs)
- .includes(tabName);
-}
-async function askModuleName(manifest) {
- while (true) {
- const name = await askQuestion('Add a new tab to which module?');
- if (!_check(manifest, name)) {
- warn(`Module ${name} does not exist.`);
- }
- else {
- return name;
- }
- }
-}
-async function askTabName(manifest) {
- while (true) {
- const name = await askQuestion('What is the name of your new tab? (eg. BinaryTree)');
- if (!isPascalCase(name)) {
- warn('Tab names must be in pascal case. (eg. BinaryTree)');
- }
- else if (check(manifest, name)) {
- warn('A tab with the same name already exists.');
- }
- else {
- return name;
- }
- }
-}
-export async function addNew(buildOpts) {
- const manifest = await retrieveManifest(buildOpts.manifest);
- const moduleName = await askModuleName(manifest);
- const tabName = await askTabName(manifest);
- // Copy module tab template into correct destination and show success message
- const tabDestination = `${buildOpts.srcDir}/tabs/${tabName}`;
- await fs.mkdir(tabDestination, { recursive: true });
- await fs.copyFile(`${cjsDirname(import.meta.url)}/templates/__tab__.tsx`, `${tabDestination}/index.tsx`);
- await fs.writeFile('modules.json', JSON.stringify({
- ...manifest,
- [moduleName]: { tabs: [...manifest[moduleName].tabs, tabName] },
- }, null, 2));
- success(`Tab ${tabName} for module ${moduleName} created at ${tabDestination}.`);
-}
diff --git a/scripts/bin/templates/templates/__bundle__.ts b/scripts/bin/templates/templates/__bundle__.ts
deleted file mode 100644
index 8ad1593cc..000000000
--- a/scripts/bin/templates/templates/__bundle__.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * A single sentence summarising the module (this sentence is displayed larger).
- *
- * Sentences describing the module. More sentences about the module.
- *
- * @module module_name
- * @author Author Name
- * @author Author Name
- */
-
-/*
- To access things like the context or module state you can just import the context
- using the import below
- */
-import { context } from 'js-slang/moduleHelpers';
-
-/**
- * Sample function. Increments a number by 1.
- *
- * @param x The number to be incremented.
- * @returns The incremented value of the number.
- */
-export function sample_function(x: number): number {
- return ++x;
-} // Then any functions or variables you want to expose to the user is exported from the bundle's index.ts file
diff --git a/scripts/bin/templates/templates/__tab__.tsx b/scripts/bin/templates/templates/__tab__.tsx
deleted file mode 100644
index 34eb95b09..000000000
--- a/scripts/bin/templates/templates/__tab__.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-
-/**
- *
- * @author
- * @author
- */
-
-/**
- * React Component props for the Tab.
- */
-type Props = {
- children?: never;
- className?: never;
- context?: any;
-};
-
-/**
- * React Component state for the Tab.
- */
-type State = {
- counter: number;
-};
-
-/**
- * The main React Component of the Tab.
- */
-class Repeat extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- counter: 0,
- };
- }
-
- public render() {
- const { counter } = this.state;
- return (
- This is spawned from the repeat package. Counter is {counter}
- );
- }
-}
-
-export default {
- /**
- * This function will be called to determine if the component will be
- * rendered. Currently spawns when the result in the REPL is "test".
- * @param {DebuggerContext} context
- * @returns {boolean}
- */
- toSpawn: (context: any) => context.result.value === 'test',
-
- /**
- * This function will be called to render the module tab in the side contents
- * on Source Academy frontend.
- * @param {DebuggerContext} context
- */
- body: (context: any) => ,
-
- /**
- * The Tab's icon tooltip in the side contents on Source Academy frontend.
- */
- label: 'Sample Tab',
-
- /**
- * BlueprintJS IconName element's name, used to render the icon which will be
- * displayed in the side contents panel.
- * @see https://blueprintjs.com/docs/#icons
- */
- iconName: 'build',
-};
\ No newline at end of file
diff --git a/scripts/bin/templates/utilities.js b/scripts/bin/templates/utilities.js
deleted file mode 100644
index c789c78da..000000000
--- a/scripts/bin/templates/utilities.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Snake case regex has been changed from `/\b[a-z]+(?:_[a-z]+)*\b/u` to `/\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u`
-// to be consistent with the naming of the `arcade_2d` and `physics_2d` modules.
-// This change should not affect other modules, since the set of possible names is only expanded.
-const snakeCaseRegex = /\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u;
-const pascalCaseRegex = /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/u;
-export function isSnakeCase(string) {
- return snakeCaseRegex.test(string);
-}
-export function isPascalCase(string) {
- return pascalCaseRegex.test(string);
-}
diff --git a/scripts/scripts_manager.js b/scripts/scripts_manager.js
new file mode 100644
index 000000000..cee34935b
--- /dev/null
+++ b/scripts/scripts_manager.js
@@ -0,0 +1,192 @@
+/**
+ * Due to the increasing complexity of the module build system, we have yet more code here
+ * to manage the build scripts.
+ *
+ * The build scripts are configured to be compiled down to a single file upon initialization
+ * of the workspace. If the `scripts/bin.js` file isn't present, then run `yarn scripts:build`
+ * to have it built.
+ */
+
+import { context as esbuild } from 'esbuild'
+import { ESLint } from 'eslint';
+import chalk from 'chalk';
+import { Command } from "commander";
+import { readFile } from 'fs/promises';
+import jest from 'jest'
+import lodash from 'lodash'
+import pathlib from 'path';
+import ts from 'typescript'
+
+const waitForQuit = () => new Promise((resolve, reject) => {
+ process.stdin.setRawMode(true);
+ process.stdin.on('data', (data) => {
+ const byteArray = [...data];
+ if (byteArray.length > 0 && byteArray[0] === 3) {
+ console.log('^C');
+ process.stdin.setRawMode(false);
+ resolve();
+ }
+ });
+ process.stdin.on('error', reject);
+});
+
+/**
+ * Run the typescript compiler programmatically
+ * @returns {Promise} Resolves to 0 on success, -1 on error
+ */
+async function runTypecheck() {
+ const parseDiagnostics = (diagnostics) => {
+ const diagStr = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
+ getNewLine: () => '\n',
+ getCurrentDirectory: () => pathlib.resolve('.'),
+ getCanonicalFileName: (name) => pathlib.basename(name),
+ });
+
+ console.log(diagStr)
+ }
+
+ // Step 1: Read the text from tsconfig.json
+ const tsconfigLocation = './scripts/src/tsconfig.json'
+ const configText = await readFile(tsconfigLocation, 'utf-8');
+
+ // Step 2: Parse the raw text into a json object
+ const { error: configJsonError, config: configJson } = ts.parseConfigFileTextToJson(tsconfigLocation, configText);
+ if (configJsonError) {
+ parseDiagnostics([configJsonError])
+ return -1;
+ }
+
+ // Step 3: Parse the json object into a config object for use by tsc
+ const { errors: parseErrors, options: tsconfig } = ts.parseJsonConfigFileContent(configJson, ts.sys, './scripts/src');
+ if (parseErrors.length > 0) {
+ parseDiagnostics(parseErrors)
+ return -1;
+ }
+
+ const tscProgram = ts.createProgram(['./scripts/src/index.ts'], tsconfig)
+
+ // Run tsc over the script source files
+ const results = tscProgram.emit()
+ const diagnostics = ts.getPreEmitDiagnostics(tscProgram)
+ .concat(results.diagnostics);
+
+ if (diagnostics.length > 0) {
+ parseDiagnostics(diagnostics)
+ return -1
+ }
+ return 0
+}
+
+const typeCheckCommand = new Command('typecheck')
+ .description('Run tsc for test files')
+ .action(runTypecheck)
+
+/**
+ * Run Jest programmatically
+ * @param {string[]} patterns
+ * @returns {Promise}
+ */
+function runJest(patterns) {
+ const [args, filePatterns] = lodash.partition(patterns ?? [], (arg) => arg.startsWith('-'));
+
+ // command.args automatically includes the source directory option
+ // which is not supported by Jest, so we need to remove it
+ const toRemove = args.findIndex((arg) => arg.startsWith('--srcDir'));
+ if (toRemove !== -1) {
+ args.splice(toRemove, 1);
+ }
+
+ const jestArgs = args.concat(filePatterns.map((pattern) => pattern.split(pathlib.win32.sep)
+ .join(pathlib.posix.sep)));
+
+ return jest.run(jestArgs, './scripts/src/jest.config.js')
+}
+
+const testCommand = new Command('test')
+ .description('Run tests for script files')
+ .allowUnknownOption()
+ .action((_, command) => runJest(command.args))
+
+async function runEsbuild({ watch }) {
+ const buildContext = await esbuild({
+ bundle: true,
+ entryPoints: ['./scripts/src/index.ts'],
+ format: 'esm',
+ logLevel: 'warning',
+ minify: true,
+ outfile: './scripts/bin.js',
+ packages: 'external',
+ platform: 'node',
+ tsconfig: './scripts/src/tsconfig.json',
+ })
+
+ if (watch) {
+ await buildContext.watch()
+ console.log('Launched esbuild in watch mode')
+ await waitForQuit()
+ } else {
+ await buildContext.rebuild()
+ }
+ await buildContext.dispose()
+}
+
+const buildCommand = new Command('build')
+ .description('Run esbuild to compile the script source files')
+ .option('-w, --watch', 'Enable watch mode', false)
+ .action(runEsbuild)
+
+async function runEslint({ fix }) {
+ const linter = new ESLint({
+ cwd: pathlib.resolve('./scripts/src'),
+ fix,
+ });
+
+ const lintResults = await linter.lintFiles('./**/*.ts')
+
+ if (fix) {
+ await ESLint.outputFixes(lintResults)
+ }
+
+ const outputFormatter = await linter.loadFormatter('stylish');
+ const formatterOutput = outputFormatter.format(lintResults);
+
+ console.log(formatterOutput)
+
+ for (const { errorCount, warningCount } of lintResults) {
+ if (errorCount > 0 || warningCount > 0) return -1;
+ }
+ return 0;
+}
+
+const lintCommand = new Command('lint')
+ .description('Run eslint over the script source files')
+ .option('--fix', 'Fix automatically fixable errors', false)
+ .action(runEslint)
+
+const mainCommand = new Command()
+ .description('Commands for managing scripts')
+ .addCommand(buildCommand)
+ .addCommand(lintCommand)
+ .addCommand(testCommand)
+ .addCommand(typeCheckCommand)
+ .action(async () => {
+ const tasks = {
+ // Jest will also run tsc, so no need to run an extra tsc check
+ // typecheck: runTypecheck,
+ eslint: () => runEslint({ fix: false }),
+ jest: () => runJest([]),
+ esbuild: () => runEsbuild({ watch: false })
+ }
+
+ // Perhaps there might be a better way to parallelize this?
+ for (const [name, func] of Object.entries(tasks)) {
+ console.log(chalk.blueBright(`Running ${name}`))
+ if (await func() === -1) return -1;
+ }
+
+ console.log(chalk.greenBright('All commands completed successfully'))
+
+ return 0;
+ })
+
+await mainCommand.parseAsync()
diff --git a/scripts/src/build/README.md b/scripts/src/build/README.md
index ef1d44bf1..e889352bd 100644
--- a/scripts/src/build/README.md
+++ b/scripts/src/build/README.md
@@ -3,4 +3,6 @@ This folder contains all the code used to build Source modules. By default, the
You can refer to the specific documentation for each type of asset:\
- [Building documentation](docs/README.md)
-- [Building modules](modules/README.md)
\ No newline at end of file
+- [Building modules](modules/README.md)
+
+Command line parsing functionality is provided by the [`commander`](https://github.com/tj/commander.js) package. An instance of `Command` is designed to be used only once per set of arguments parsed, so to facilitate testing several of the `Command` instances have been written as functions (e.g. `getBuildCommand`) that return a new instance when called.
\ No newline at end of file
diff --git a/scripts/src/build/__tests__/buildAll.test.ts b/scripts/src/build/__tests__/buildAll.test.ts
index 90043ecdc..ee865fee5 100644
--- a/scripts/src/build/__tests__/buildAll.test.ts
+++ b/scripts/src/build/__tests__/buildAll.test.ts
@@ -1,125 +1,125 @@
-import { getBuildAllCommand } from '..';
-import * as modules from '../modules';
-import * as docsModule from '../docs';
-import * as lintModule from '../prebuild/eslint';
-import * as tscModule from '../prebuild/tsc';
-import { MockedFunction } from 'jest-mock';
-
-import fs from 'fs/promises';
-import pathlib from 'path';
-
-jest.mock('../prebuild/tsc');
-jest.mock('../prebuild/eslint');
-
-jest.mock('esbuild', () => ({
- build: jest.fn().mockResolvedValue({ outputFiles: [] }),
-}));
-
-jest.spyOn(modules, 'buildModules');
-jest.spyOn(docsModule, 'buildJsons');
-jest.spyOn(docsModule, 'buildHtml');
-
-const asMock = any>(func: T) => func as MockedFunction;
-const runCommand = (...args: string[]) => getBuildAllCommand().parseAsync(args, { from: 'user' });
-
-describe('test build all command', () => {
- it('should create the output directories, copy the manifest, and call all build functions', async () => {
- await runCommand();
-
- expect(fs.mkdir)
- .toBeCalledWith('build', { recursive: true })
-
- expect(fs.copyFile)
- .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
-
- expect(docsModule.initTypedoc)
- .toHaveBeenCalledTimes(1);
-
- expect(docsModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- expect(docsModule.buildHtml)
- .toHaveBeenCalledTimes(1);
-
- expect(modules.buildModules)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should exit with code 1 if tsc returns with an error', async () => {
- try {
- await runCommand('--tsc');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should exit with code 1 if eslint returns with an error', async () => {
- try {
- await runCommand('--lint');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it('should exit with code 1 if buildJsons returns with an error', async () => {
- asMock(docsModule.buildJsons).mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- })
-
- it('should exit with code 1 if buildModules returns with an error', async () => {
- asMock(modules.buildModules).mockResolvedValueOnce([['bundle', 'test0', { severity: 'error' }]])
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it('should exit with code 1 if buildHtml returns with an error', async () => {
- asMock(docsModule.buildHtml).mockResolvedValueOnce({
- elapsed: 0,
- result: {
- severity: 'error',
- }
- });
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
-
- expect(docsModule.buildHtml)
- .toHaveBeenCalledTimes(1);
- });
-});
+import { getBuildAllCommand } from '..';
+import * as modules from '../modules';
+import * as docsModule from '../docs';
+import * as lintModule from '../prebuild/lint';
+import * as tscModule from '../prebuild/tsc';
+import { MockedFunction } from 'jest-mock';
+
+import fs from 'fs/promises';
+import pathlib from 'path';
+
+jest.mock('../prebuild/tsc');
+jest.mock('../prebuild/lint');
+
+jest.mock('esbuild', () => ({
+ build: jest.fn().mockResolvedValue({ outputFiles: [] }),
+}));
+
+jest.spyOn(modules, 'buildModules');
+jest.spyOn(docsModule, 'buildJsons');
+jest.spyOn(docsModule, 'buildHtml');
+
+const asMock = any>(func: T) => func as MockedFunction;
+const runCommand = (...args: string[]) => getBuildAllCommand().parseAsync(args, { from: 'user' });
+
+describe('test build all command', () => {
+ it('should create the output directories, copy the manifest, and call all build functions', async () => {
+ await runCommand();
+
+ expect(fs.mkdir)
+ .toBeCalledWith('build', { recursive: true })
+
+ expect(fs.copyFile)
+ .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
+
+ expect(docsModule.initTypedoc)
+ .toHaveBeenCalledTimes(1);
+
+ expect(docsModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ expect(docsModule.buildHtml)
+ .toHaveBeenCalledTimes(1);
+
+ expect(modules.buildModules)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should exit with code 1 if tsc returns with an error', async () => {
+ try {
+ await runCommand('--tsc');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should exit with code 1 if eslint returns with an error', async () => {
+ try {
+ await runCommand('--lint');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it('should exit with code 1 if buildJsons returns with an error', async () => {
+ asMock(docsModule.buildJsons).mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ })
+
+ it('should exit with code 1 if buildModules returns with an error', async () => {
+ asMock(modules.buildModules).mockResolvedValueOnce([['bundle', 'test0', { severity: 'error' }]])
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it('should exit with code 1 if buildHtml returns with an error', async () => {
+ asMock(docsModule.buildHtml).mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ severity: 'error',
+ }
+ });
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+
+ expect(docsModule.buildHtml)
+ .toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/scripts/src/build/__tests__/buildUtils.test.ts b/scripts/src/build/__tests__/buildUtils.test.ts
index 260284e7d..51451e9d6 100644
--- a/scripts/src/build/__tests__/buildUtils.test.ts
+++ b/scripts/src/build/__tests__/buildUtils.test.ts
@@ -1,74 +1,74 @@
-import { retrieveBundlesAndTabs } from '../buildUtils';
-
-describe('Test retrieveBundlesAndTabs', () => {
- it('should return all bundles and tabs when null is passed for modules', async () => {
- const result = await retrieveBundlesAndTabs('', null, null);
-
- expect(result.bundles)
- .toEqual(expect.arrayContaining(['test0', 'test1', 'test2']));
- expect(result.modulesSpecified)
- .toBe(false);
- expect(result.tabs)
- .toEqual(expect.arrayContaining(['tab0', 'tab1']));
- });
-
- it('should return only specific bundles and their tabs when an array is passed for modules', async () => {
- const result = await retrieveBundlesAndTabs('', ['test0'], null);
-
- expect(result.bundles)
- .toEqual(expect.arrayContaining(['test0']));
- expect(result.modulesSpecified)
- .toBe(true);
- expect(result.tabs)
- .toEqual(expect.arrayContaining(['tab0']));
- });
-
- it('should return nothing when an empty array is passed for modules', async () => {
- const result = await retrieveBundlesAndTabs('', [], null);
-
- expect(result.bundles)
- .toEqual([]);
- expect(result.modulesSpecified)
- .toBe(true);
- expect(result.tabs)
- .toEqual([]);
- });
-
- it('should return tabs from the specified modules, and concatenate specified tabs', async () => {
- const result = await retrieveBundlesAndTabs('', ['test0'], ['tab1']);
-
- expect(result.bundles)
- .toEqual(['test0']);
- expect(result.modulesSpecified)
- .toBe(true);
- expect(result.tabs)
- .toEqual(expect.arrayContaining(['tab0', 'tab1']));
- });
-
- it('should return only specified tabs when addTabs is false', async () => {
- const result = await retrieveBundlesAndTabs('', ['test0'], ['tab1'], false);
-
- expect(result.bundles)
- .toEqual(['test0']);
- expect(result.modulesSpecified)
- .toBe(true);
- expect(result.tabs)
- .toEqual(['tab1']);
- });
-
- it('should throw an exception when encountering unknown modules or tabs', () => Promise.all([
- expect(retrieveBundlesAndTabs('', ['random'], null)).rejects.toMatchObject(new Error('Unknown modules: random')),
- expect(retrieveBundlesAndTabs('', [], ['random1', 'random2'])).rejects.toMatchObject(new Error('Unknown tabs: random1, random2'))
- ]));
-
- it('should always return unique modules and tabs', async () => {
- const result = await retrieveBundlesAndTabs('', ['test0', 'test0'], ['tab0']);
-
- expect(result.bundles)
- .toEqual(['test0']);
- expect(result.modulesSpecified)
- .toBe(true);
- expect(result.tabs)
- .toEqual(['tab0']);
- })
-});
+import { retrieveBundlesAndTabs } from '../buildUtils';
+
+describe('Test retrieveBundlesAndTabs', () => {
+ it('should return all bundles and tabs when null is passed for modules', async () => {
+ const result = await retrieveBundlesAndTabs('', null, null);
+
+ expect(result.bundles)
+ .toEqual(expect.arrayContaining(['test0', 'test1', 'test2']));
+ expect(result.modulesSpecified)
+ .toBe(false);
+ expect(result.tabs)
+ .toEqual(expect.arrayContaining(['tab0', 'tab1']));
+ });
+
+ it('should return only specific bundles and their tabs when an array is passed for modules', async () => {
+ const result = await retrieveBundlesAndTabs('', ['test0'], null);
+
+ expect(result.bundles)
+ .toEqual(expect.arrayContaining(['test0']));
+ expect(result.modulesSpecified)
+ .toBe(true);
+ expect(result.tabs)
+ .toEqual(expect.arrayContaining(['tab0']));
+ });
+
+ it('should return nothing when an empty array is passed for modules', async () => {
+ const result = await retrieveBundlesAndTabs('', [], null);
+
+ expect(result.bundles)
+ .toEqual([]);
+ expect(result.modulesSpecified)
+ .toBe(true);
+ expect(result.tabs)
+ .toEqual([]);
+ });
+
+ it('should return tabs from the specified modules, and concatenate specified tabs', async () => {
+ const result = await retrieveBundlesAndTabs('', ['test0'], ['tab1']);
+
+ expect(result.bundles)
+ .toEqual(['test0']);
+ expect(result.modulesSpecified)
+ .toBe(true);
+ expect(result.tabs)
+ .toEqual(expect.arrayContaining(['tab0', 'tab1']));
+ });
+
+ it('should return only specified tabs when addTabs is false', async () => {
+ const result = await retrieveBundlesAndTabs('', ['test0'], ['tab1'], false);
+
+ expect(result.bundles)
+ .toEqual(['test0']);
+ expect(result.modulesSpecified)
+ .toBe(true);
+ expect(result.tabs)
+ .toEqual(['tab1']);
+ });
+
+ it('should throw an exception when encountering unknown modules or tabs', () => Promise.all([
+ expect(retrieveBundlesAndTabs('', ['random'], null)).rejects.toMatchObject(new Error('Unknown modules: random')),
+ expect(retrieveBundlesAndTabs('', [], ['random1', 'random2'])).rejects.toMatchObject(new Error('Unknown tabs: random1, random2'))
+ ]));
+
+ it('should always return unique modules and tabs', async () => {
+ const result = await retrieveBundlesAndTabs('', ['test0', 'test0'], ['tab0']);
+
+ expect(result.bundles)
+ .toEqual(['test0']);
+ expect(result.modulesSpecified)
+ .toBe(true);
+ expect(result.tabs)
+ .toEqual(['tab0']);
+ })
+});
diff --git a/scripts/src/build/buildUtils.ts b/scripts/src/build/buildUtils.ts
index 84a97ed67..fd1b8600e 100644
--- a/scripts/src/build/buildUtils.ts
+++ b/scripts/src/build/buildUtils.ts
@@ -1,312 +1,312 @@
-import chalk from 'chalk';
-import { Command, Option } from 'commander';
-import { Table } from 'console-table-printer';
-import fs from 'fs/promises';
-import path from 'path';
-
-import { retrieveManifest } from '../scriptUtils.js';
-
-import {
- type AssetTypes,
- type BuildResult,
- type OperationResult,
- type OverallResult,
- type UnreducedResult,
- Assets,
-} from './types.js';
-
-export const divideAndRound = (dividend: number, divisor: number, round: number = 2) => (dividend / divisor).toFixed(round);
-
-export const fileSizeFormatter = (size?: number) => {
- if (typeof size !== 'number') return '-';
-
- size /= 1000;
- if (size < 0.01) return '<0.01 KB';
- if (size >= 100) return `${divideAndRound(size, 1000)} MB`;
- return `${size.toFixed(2)} KB`;
-};
-
-export const logResult = (
- unreduced: UnreducedResult[],
- verbose: boolean,
-) => {
- const overallResult = unreduced.reduce((res, [type, name, entry]) => {
- if (!res[type]) {
- res[type] = {
- severity: 'success',
- results: {},
- };
- }
-
- if (entry.severity === 'error') res[type].severity = 'error';
- else if (res[type].severity === 'success' && entry.severity === 'warn') res[type].severity = 'warn';
-
- res[type].results[name] = entry;
- return res;
- }, {} as Partial>>);
- return console.log(Object.entries(overallResult)
- .map(([label, toLog]) => {
- if (!toLog) return null;
-
- const upperCaseLabel = label[0].toUpperCase() + label.slice(1);
- const { severity: overallSev, results } = toLog;
- const entries = Object.entries(results);
- if (entries.length === 0) return '';
-
- if (!verbose) {
- if (overallSev === 'success') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}\n`;
- }
- if (overallSev === 'warn') {
- return chalk.cyanBright(`${upperCaseLabel}s built with ${chalk.yellowBright('warnings')}:\n${
- entries
- .filter(([, { severity }]) => severity === 'warn')
- .map(([bundle, { error }], i) => chalk.yellowBright(`${i + 1}. ${bundle}: ${error}`))
- .join('\n')}\n`);
- }
-
- return chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors:\n${
- entries
- .filter(([, { severity }]) => severity !== 'success')
- .map(([bundle, { error, severity }], i) => (severity === 'error'
- ? chalk.redBright(`${i + 1}. Error ${bundle}: ${error}`)
- : chalk.yellowBright(`${i + 1}. Warning ${bundle}: +${error}`)))
- .join('\n')}\n`);
- }
-
- const outputTable = new Table({
- columns: [{
- name: 'name',
- title: upperCaseLabel,
- },
- {
- name: 'severity',
- title: 'Status',
- },
- {
- name: 'elapsed',
- title: 'Elapsed (s)',
- },
- {
- name: 'fileSize',
- title: 'File Size',
- },
- {
- name: 'error',
- title: 'Errors',
- }],
- });
-
- entries.forEach(([name, { elapsed, severity, error, fileSize }]) => {
- if (severity === 'error') {
- outputTable.addRow({
- name,
- elapsed: '-',
- error,
- fileSize: '-',
- severity: 'Error',
- }, { color: 'red' });
- } else if (severity === 'warn') {
- outputTable.addRow({
- name,
- elapsed: divideAndRound(elapsed, 1000, 2),
- error,
- fileSize: fileSizeFormatter(fileSize),
- severity: 'Warning',
- }, { color: 'yellow' });
- } else {
- outputTable.addRow({
- name,
- elapsed: divideAndRound(elapsed, 1000, 2),
- error: '-',
- fileSize: fileSizeFormatter(fileSize),
- severity: 'Success',
- }, { color: 'green' });
- }
- });
-
- if (overallSev === 'success') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}:\n${outputTable.render()}\n`;
- }
- if (overallSev === 'warn') {
- return `${chalk.cyanBright(`${upperCaseLabel}s built`)} with ${chalk.yellowBright('warnings')}:\n${outputTable.render()}\n`;
- }
- return `${chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors`)}:\n${outputTable.render()}\n`;
- })
- .filter((str) => str !== null)
- .join('\n'));
-};
-
-/**
- * Call this function to exit with code 1 when there are errors with the build command that ran
- */
-export const exitOnError = (
- results: (UnreducedResult | OperationResult | null)[],
- ...others: (UnreducedResult | OperationResult | null)[]
-) => {
- results.concat(others)
- .forEach((entry) => {
- if (!entry) return;
-
- if (Array.isArray(entry)) {
- const [,,{ severity }] = entry;
- if (severity === 'error') process.exit(1);
- } else if (entry.severity === 'error') process.exit(1);
- });
-};
-
-export const retrieveTabs = async (manifestFile: string, tabs: string[] | null) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownTabs = Object.values(manifest)
- .flatMap((x) => x.tabs);
-
- if (tabs === null) {
- tabs = knownTabs;
- } else {
- const unknownTabs = tabs.filter((t) => !knownTabs.includes(t));
-
- if (unknownTabs.length > 0) {
- throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
- }
- }
-
- return tabs;
-};
-
-export const retrieveBundles = async (manifestFile: string, modules: string[] | null) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownBundles = Object.keys(manifest);
-
- if (modules !== null) {
- // Some modules were specified
- const unknownModules = modules.filter((m) => !knownBundles.includes(m));
-
- if (unknownModules.length > 0) {
- throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
- }
- return modules;
- }
- return knownBundles;
-};
-
-/**
- * Determines which bundles and tabs to build based on the user's input.
- *
- * If no modules and no tabs are specified, it is assumed the user wants to
- * build everything.
- *
- * If modules but no tabs are specified, it is assumed the user only wants to
- * build those bundles (and possibly those modules' tabs based on
- * shouldAddModuleTabs).
- *
- * If tabs but no modules are specified, it is assumed the user only wants to
- * build those tabs.
- *
- * If both modules and tabs are specified, both of the above apply and are
- * combined.
- *
- * @param modules module names specified by the user
- * @param tabOptions tab names specified by the user
- * @param shouldAddModuleTabs whether to also automatically include the tabs of
- * specified modules
- */
-export const retrieveBundlesAndTabs = async (
- manifestFile: string,
- modules: string[] | null,
- tabOptions: string[] | null,
- shouldAddModuleTabs: boolean = true,
-) => {
- const manifest = await retrieveManifest(manifestFile);
- const knownBundles = Object.keys(manifest);
- const knownTabs = Object
- .values(manifest)
- .flatMap((x) => x.tabs);
-
- let bundles: string[] = [];
- let tabs: string[] = [];
-
- function addSpecificModules() {
- // If unknown modules were specified, error
- const unknownModules = modules.filter((m) => !knownBundles.includes(m));
- if (unknownModules.length > 0) {
- throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
- }
-
- bundles = bundles.concat(modules);
-
- if (shouldAddModuleTabs) {
- // Add the modules' tabs too
- tabs = [...tabs, ...modules.flatMap((bundle) => manifest[bundle].tabs)];
- }
- }
- function addSpecificTabs() {
- // If unknown tabs were specified, error
- const unknownTabs = tabOptions.filter((t) => !knownTabs.includes(t));
- if (unknownTabs.length > 0) {
- throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
- }
-
- tabs = tabs.concat(tabOptions);
- }
- function addAllBundles() {
- bundles = bundles.concat(knownBundles);
- }
- function addAllTabs() {
- tabs = tabs.concat(knownTabs);
- }
-
- if (modules === null && tabOptions === null) {
- addAllBundles();
- addAllTabs();
- } else {
- if (modules !== null) addSpecificModules();
- if (tabOptions !== null) addSpecificTabs();
- }
-
- return {
- bundles: [...new Set(bundles)],
- tabs: [...new Set(tabs)],
- modulesSpecified: modules !== null,
- };
-};
-
-export const bundleNameExpander = (srcdir: string) => (name: string) => path.join(srcdir, 'bundles', name, 'index.ts');
-export const tabNameExpander = (srcdir: string) => (name: string) => path.join(srcdir, 'tabs', name, 'index.tsx');
-
-export const createBuildCommand = (label: string, addLint: boolean) => {
- const cmd = new Command(label)
- .option('--outDir ', 'Output directory', 'build')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-v, --verbose', 'Display more information about the build results', false);
-
- if (addLint) {
- cmd.option('--tsc', 'Run tsc before building')
- .option('--lint', 'Run eslint before building')
- .addOption(new Option('--fix', 'Ask eslint to autofix linting errors')
- .implies({ lint: true }));
- }
-
- return cmd;
-};
-
-/**
- * Create the output directory's root folder
- */
-export const createOutDir = (outDir: string) => fs.mkdir(outDir, { recursive: true });
-
-/**
- * Copy the manifest to the output folder. The root output folder will be created
- * if it does not already exist.
- */
-export const copyManifest = ({ manifest, outDir }: { manifest: string, outDir: string }) => createOutDir(outDir)
- .then(() => fs.copyFile(
- manifest, path.join(outDir, manifest),
- ));
-
-/**
- * Create the output directories for each type of asset.
- */
-export const createBuildDirs = (outDir: string) => Promise.all(
- Assets.map((asset) => fs.mkdir(path.join(outDir, `${asset}s`), { recursive: true })),
-);
+import chalk from 'chalk';
+import { Command, Option } from 'commander';
+import { Table } from 'console-table-printer';
+import fs from 'fs/promises';
+import path from 'path';
+
+import { retrieveManifest } from '../scriptUtils.js';
+
+import {
+ type AssetTypes,
+ type BuildResult,
+ type OperationResult,
+ type OverallResult,
+ type UnreducedResult,
+ Assets,
+} from './types.js';
+
+export const divideAndRound = (dividend: number, divisor: number, round: number = 2) => (dividend / divisor).toFixed(round);
+
+export const fileSizeFormatter = (size?: number) => {
+ if (typeof size !== 'number') return '-';
+
+ size /= 1000;
+ if (size < 0.01) return '<0.01 KB';
+ if (size >= 100) return `${divideAndRound(size, 1000)} MB`;
+ return `${size.toFixed(2)} KB`;
+};
+
+export const logResult = (
+ unreduced: UnreducedResult[],
+ verbose: boolean,
+) => {
+ const overallResult = unreduced.reduce((res, [type, name, entry]) => {
+ if (!res[type]) {
+ res[type] = {
+ severity: 'success',
+ results: {},
+ };
+ }
+
+ if (entry.severity === 'error') res[type].severity = 'error';
+ else if (res[type].severity === 'success' && entry.severity === 'warn') res[type].severity = 'warn';
+
+ res[type].results[name] = entry;
+ return res;
+ }, {} as Partial>>);
+ return console.log(Object.entries(overallResult)
+ .map(([label, toLog]) => {
+ if (!toLog) return null;
+
+ const upperCaseLabel = label[0].toUpperCase() + label.slice(1);
+ const { severity: overallSev, results } = toLog;
+ const entries = Object.entries(results);
+ if (entries.length === 0) return '';
+
+ if (!verbose) {
+ if (overallSev === 'success') {
+ return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}\n`;
+ }
+ if (overallSev === 'warn') {
+ return chalk.cyanBright(`${upperCaseLabel}s built with ${chalk.yellowBright('warnings')}:\n${
+ entries
+ .filter(([, { severity }]) => severity === 'warn')
+ .map(([bundle, { error }], i) => chalk.yellowBright(`${i + 1}. ${bundle}: ${error}`))
+ .join('\n')}\n`);
+ }
+
+ return chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors:\n${
+ entries
+ .filter(([, { severity }]) => severity !== 'success')
+ .map(([bundle, { error, severity }], i) => (severity === 'error'
+ ? chalk.redBright(`${i + 1}. Error ${bundle}: ${error}`)
+ : chalk.yellowBright(`${i + 1}. Warning ${bundle}: +${error}`)))
+ .join('\n')}\n`);
+ }
+
+ const outputTable = new Table({
+ columns: [{
+ name: 'name',
+ title: upperCaseLabel,
+ },
+ {
+ name: 'severity',
+ title: 'Status',
+ },
+ {
+ name: 'elapsed',
+ title: 'Elapsed (s)',
+ },
+ {
+ name: 'fileSize',
+ title: 'File Size',
+ },
+ {
+ name: 'error',
+ title: 'Errors',
+ }],
+ });
+
+ entries.forEach(([name, { elapsed, severity, error, fileSize }]) => {
+ if (severity === 'error') {
+ outputTable.addRow({
+ name,
+ elapsed: '-',
+ error,
+ fileSize: '-',
+ severity: 'Error',
+ }, { color: 'red' });
+ } else if (severity === 'warn') {
+ outputTable.addRow({
+ name,
+ elapsed: divideAndRound(elapsed, 1000, 2),
+ error,
+ fileSize: fileSizeFormatter(fileSize),
+ severity: 'Warning',
+ }, { color: 'yellow' });
+ } else {
+ outputTable.addRow({
+ name,
+ elapsed: divideAndRound(elapsed, 1000, 2),
+ error: '-',
+ fileSize: fileSizeFormatter(fileSize),
+ severity: 'Success',
+ }, { color: 'green' });
+ }
+ });
+
+ if (overallSev === 'success') {
+ return `${chalk.cyanBright(`${upperCaseLabel}s built`)} ${chalk.greenBright('successfully')}:\n${outputTable.render()}\n`;
+ }
+ if (overallSev === 'warn') {
+ return `${chalk.cyanBright(`${upperCaseLabel}s built`)} with ${chalk.yellowBright('warnings')}:\n${outputTable.render()}\n`;
+ }
+ return `${chalk.cyanBright(`${upperCaseLabel}s build ${chalk.redBright('failed')} with errors`)}:\n${outputTable.render()}\n`;
+ })
+ .filter((str) => str !== null)
+ .join('\n'));
+};
+
+/**
+ * Call this function to exit with code 1 when there are errors with the build command that ran
+ */
+export const exitOnError = (
+ results: (UnreducedResult | OperationResult | null)[],
+ ...others: (UnreducedResult | OperationResult | null)[]
+) => {
+ results.concat(others)
+ .forEach((entry) => {
+ if (!entry) return;
+
+ if (Array.isArray(entry)) {
+ const [,,{ severity }] = entry;
+ if (severity === 'error') process.exit(1);
+ } else if (entry.severity === 'error') process.exit(1);
+ });
+};
+
+export const retrieveTabs = async (manifestFile: string, tabs: string[] | null) => {
+ const manifest = await retrieveManifest(manifestFile);
+ const knownTabs = Object.values(manifest)
+ .flatMap((x) => x.tabs);
+
+ if (tabs === null) {
+ tabs = knownTabs;
+ } else {
+ const unknownTabs = tabs.filter((t) => !knownTabs.includes(t));
+
+ if (unknownTabs.length > 0) {
+ throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
+ }
+ }
+
+ return tabs;
+};
+
+export const retrieveBundles = async (manifestFile: string, modules: string[] | null) => {
+ const manifest = await retrieveManifest(manifestFile);
+ const knownBundles = Object.keys(manifest);
+
+ if (modules !== null) {
+ // Some modules were specified
+ const unknownModules = modules.filter((m) => !knownBundles.includes(m));
+
+ if (unknownModules.length > 0) {
+ throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
+ }
+ return modules;
+ }
+ return knownBundles;
+};
+
+export const bundleNameExpander = (srcdir: string) => (name: string) => path.join(srcdir, 'bundles', name, 'index.ts');
+export const tabNameExpander = (srcdir: string) => (name: string) => path.join(srcdir, 'tabs', name, 'index.tsx');
+
+export const createBuildCommand = (label: string, addLint: boolean) => {
+ const cmd = new Command(label)
+ .option('--outDir ', 'Output directory', 'build')
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .option('-v, --verbose', 'Display more information about the build results', false);
+
+ if (addLint) {
+ cmd.option('--tsc', 'Run tsc before building')
+ .option('--lint', 'Run eslint before building')
+ .addOption(new Option('--fix', 'Ask eslint to autofix linting errors')
+ .implies({ lint: true }));
+ }
+
+ return cmd;
+};
+
+/**
+ * Create the output directory's root folder
+ */
+export const createOutDir = (outDir: string) => fs.mkdir(outDir, { recursive: true });
+
+/**
+ * Copy the manifest to the output folder. The root output folder will be created
+ * if it does not already exist.
+ */
+export const copyManifest = ({ manifest, outDir }: { manifest: string, outDir: string }) => createOutDir(outDir)
+ .then(() => fs.copyFile(
+ manifest, path.join(outDir, manifest),
+ ));
+
+/**
+ * Create the output directories for each type of asset.
+ */
+export const createBuildDirs = (outDir: string) => Promise.all(
+ Assets.map((asset) => fs.mkdir(path.join(outDir, `${asset}s`), { recursive: true })),
+);
+
+/**
+ * Determines which bundles and tabs to build based on the user's input.
+ *
+ * If no modules and no tabs are specified, it is assumed the user wants to
+ * build everything.
+ *
+ * If modules but no tabs are specified, it is assumed the user only wants to
+ * build those bundles (and possibly those modules' tabs based on
+ * shouldAddModuleTabs).
+ *
+ * If tabs but no modules are specified, it is assumed the user only wants to
+ * build those tabs.
+ *
+ * If both modules and tabs are specified, both of the above apply and are
+ * combined.
+ *
+ * @param modules module names specified by the user
+ * @param tabOptions tab names specified by the user
+ * @param shouldAddModuleTabs whether to also automatically include the tabs of
+ * specified modules
+ */
+export const retrieveBundlesAndTabs = async (
+ manifestFile: string,
+ modules: string[] | null,
+ tabOptions: string[] | null,
+ shouldAddModuleTabs: boolean = true,
+) => {
+ const manifest = await retrieveManifest(manifestFile);
+ const knownBundles = Object.keys(manifest);
+ const knownTabs = Object
+ .values(manifest)
+ .flatMap((x) => x.tabs);
+
+ let bundles: string[] = [];
+ let tabs: string[] = [];
+
+ function addSpecificModules() {
+ // If unknown modules were specified, error
+ const unknownModules = modules.filter((m) => !knownBundles.includes(m));
+ if (unknownModules.length > 0) {
+ throw new Error(`Unknown modules: ${unknownModules.join(', ')}`);
+ }
+
+ bundles = bundles.concat(modules);
+
+ if (shouldAddModuleTabs) {
+ // Add the modules' tabs too
+ tabs = [...tabs, ...modules.flatMap((bundle) => manifest[bundle].tabs)];
+ }
+ }
+ function addSpecificTabs() {
+ // If unknown tabs were specified, error
+ const unknownTabs = tabOptions.filter((t) => !knownTabs.includes(t));
+ if (unknownTabs.length > 0) {
+ throw new Error(`Unknown tabs: ${unknownTabs.join(', ')}`);
+ }
+
+ tabs = tabs.concat(tabOptions);
+ }
+ function addAllBundles() {
+ bundles = bundles.concat(knownBundles);
+ }
+ function addAllTabs() {
+ tabs = tabs.concat(knownTabs);
+ }
+
+ if (modules === null && tabOptions === null) {
+ addAllBundles();
+ addAllTabs();
+ } else {
+ if (modules !== null) addSpecificModules();
+ if (tabOptions !== null) addSpecificTabs();
+ }
+
+ return {
+ bundles: [...new Set(bundles)],
+ tabs: [...new Set(tabs)],
+ modulesSpecified: modules !== null,
+ };
+};
diff --git a/scripts/src/build/dev.ts b/scripts/src/build/dev.ts
index e3c812ec8..50f7800d4 100644
--- a/scripts/src/build/dev.ts
+++ b/scripts/src/build/dev.ts
@@ -1,380 +1,360 @@
-import chalk from 'chalk';
-import { context as esbuild } from 'esbuild';
-import type { Application } from 'typedoc';
-
-import { buildHtml, buildJsons, initTypedoc, logHtmlResult } from './docs/index.js';
-import { bundleOptions, reduceBundleOutputFiles } from './modules/bundle.js';
-import { reduceTabOutputFiles, tabOptions } from './modules/tab.js';
-import {
- bundleNameExpander,
- copyManifest,
- createBuildCommand,
- createBuildDirs,
- divideAndRound,
- logResult,
- retrieveBundlesAndTabs,
- tabNameExpander,
-} from './buildUtils.js';
-import type { BuildCommandInputs, UnreducedResult } from './types.js';
-
-/**
- * Wait until the user presses 'ctrl+c' on the keyboard
- */
-const waitForQuit = () => new Promise((resolve, reject) => {
- process.stdin.setRawMode(true);
- process.stdin.on('data', (data) => {
- const byteArray = [...data];
- if (byteArray.length > 0 && byteArray[0] === 3) {
- console.log('^C');
- process.stdin.setRawMode(false);
- resolve();
- }
- });
- process.stdin.on('error', reject);
-});
-
-type ContextOptions = Record<'srcDir' | 'outDir', string>;
-const getBundleContext = ({ srcDir, outDir }: ContextOptions, bundles: string[], app?: Application) => esbuild({
- ...bundleOptions,
- entryPoints: bundles.map(bundleNameExpander(srcDir)),
- outbase: outDir,
- outdir: outDir,
- plugins: [{
- name: 'Bundle Compiler',
- async setup(pluginBuild) {
- let jsonPromise: Promise | null = null;
- if (app) {
- app.convertAndWatch(async (project) => {
- console.log(chalk.magentaBright('Beginning jsons build...'));
- jsonPromise = buildJsons(project, {
- outDir,
- bundles,
- });
- });
- }
-
- let startTime: number;
- pluginBuild.onStart(() => {
- console.log(chalk.magentaBright('Beginning bundles build...'));
- startTime = performance.now();
- });
-
- pluginBuild.onEnd(async ({ outputFiles }) => {
- const [mainResults, jsonResults] = await Promise.all([
- reduceBundleOutputFiles(outputFiles, startTime, outDir),
- jsonPromise || Promise.resolve([]),
- ]);
- logResult(mainResults.concat(jsonResults), false);
-
- console.log(chalk.gray(`Bundles took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
- });
- },
- }],
-});
-
-const getTabContext = ({ srcDir, outDir }: ContextOptions, tabs: string[]) => esbuild({
- ...tabOptions,
- entryPoints: tabs.map(tabNameExpander(srcDir)),
- outbase: outDir,
- outdir: outDir,
- external: ['react*', 'react-dom'],
- plugins: [{
- name: 'Tab Compiler',
- setup(pluginBuild) {
- let startTime: number;
- pluginBuild.onStart(() => {
- console.log(chalk.magentaBright('Beginning tabs build...'));
- startTime = performance.now();
- });
-
- pluginBuild.onEnd(async ({ outputFiles }) => {
- const mainResults = await reduceTabOutputFiles(outputFiles, startTime, outDir);
- logResult(mainResults, false);
-
- console.log(chalk.gray(`Tabs took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
- });
- },
- }],
-});
-
-// const serveContext = async (context: Awaited>) => {
-// const { port } = await context.serve({
-// host: '127.0.0.2',
-// onRequest: ({ method, path: urlPath, remoteAddress, timeInMS }) => console.log(`[${new Date()
-// .toISOString()}] ${chalk.gray(remoteAddress)} "${chalk.cyan(`${method} ${urlPath}`)}": Response Time: ${
-// chalk.magentaBright(`${divideAndRound(timeInMS, 1000, 2)}s`)}`),
-// });
-
-// return port;
-// };
-
-type WatchCommandInputs = {
- docs: boolean;
-} & BuildCommandInputs;
-
-export const watchCommand = createBuildCommand('watch', false)
- .description('Run esbuild in watch mode, rebuilding on every detected file system change')
- .option('--no-docs', 'Don\'t rebuild documentation')
- .action(async (opts: WatchCommandInputs) => {
- const [{ bundles, tabs }] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, null, null),
- createBuildDirs(opts.outDir),
- copyManifest(opts),
- ]);
-
- let app: Application | null = null;
- if (opts.docs) {
- ({ result: [app] } = await initTypedoc({
- srcDir: opts.srcDir,
- bundles,
- verbose: false,
- }, true));
- }
-
- const [bundlesContext, tabsContext] = await Promise.all([
- getBundleContext(opts, bundles, app),
- getTabContext(opts, tabs),
- ]);
-
- console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes\nPress CTRL + C to stop`));
- await Promise.all([bundlesContext.watch(), tabsContext.watch()]);
- await waitForQuit();
- console.log(chalk.yellowBright('Stopping...'));
-
- const [htmlResult] = await Promise.all([
- opts.docs
- ? buildHtml(app, app.convert(), {
- outDir: opts.outDir,
- modulesSpecified: false,
- })
- : Promise.resolve(null),
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- copyManifest(opts),
- ]);
- logHtmlResult(htmlResult);
- });
-
-/*
-type DevCommandInputs = {
- docs: boolean;
-
- ip: string | null;
- port: number | null;
-
- watch: boolean;
- serve: boolean;
-} & BuildCommandInputs;
-
-const devCommand = createBuildCommand('dev')
- .description('Use this command to leverage esbuild\'s automatic rebuilding capapbilities.'
- + ' Use --watch to rebuild every time the file system detects changes and'
- + ' --serve to serve modules using a special HTTP server that rebuilds on each request.'
- + ' If neither is specified then --serve is assumed')
- .option('--no-docs', 'Don\'t rebuild documentation')
- .option('-w, --watch', 'Rebuild on file system changes', false)
- .option('-s, --serve', 'Run the HTTP server, and rebuild on every request', false)
- .option('-i, --ip', 'Host interface to bind to', null)
- .option('-p, --port', 'Port to bind for the server to bind to', (value) => {
- const parsedInt = parseInt(value);
- if (isNaN(parsedInt) || parsedInt < 1 || parsedInt > 65535) {
- throw new InvalidArgumentError(`Expected port to be a valid number between 1-65535, got ${value}!`);
- }
- return parsedInt;
- }, null)
- .action(async ({ verbose, ...opts }: DevCommandInputs) => {
- const shouldWatch = opts.watch;
- const shouldServe = opts.serve || !opts.watch;
-
- if (!shouldServe) {
- if (opts.ip) console.log(chalk.yellowBright('--ip option specified without --serve!'));
- if (opts.port) console.log(chalk.yellowBright('--port option specified without --serve!'));
- }
-
- const [{ bundles, tabs }] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, null, null),
- fsPromises.mkdir(`${opts.outDir}/bundles/`, { recursive: true }),
- fsPromises.mkdir(`${opts.outDir}/tabs/`, { recursive: true }),
- fsPromises.mkdir(`${opts.outDir}/jsons/`, { recursive: true }),
- fsPromises.copyFile(opts.manifest, `${opts.outDir}/${opts.manifest}`),
- ]);
-
-
- const [bundlesContext, tabsContext] = await Promise.all([
- getBundleContext(opts, bundles),
- getTabContext(opts, tabs),
- ]);
-
- await Promise.all([
- bundlesContext.watch(),
- tabsContext.watch(),
- ]);
-
- await Promise.all([
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- ]);
-
- await waitForQuit();
-
-
- if (opts.watch) {
- await Promise.all([
- bundlesContext.watch(),
- tabsContext.watch(),
- ]);
- }
-
- let httpServer: http.Server | null = null;
- if (opts.serve) {
- const [bundlesPort, tabsPort] = await Promise.all([
- serveContext(bundlesContext),
- serveContext(tabsContext),
- ]);
-
- httpServer = http.createServer((req, res) => {
- const urlSegments = req.url.split('/');
- if (urlSegments.length === 3) {
- const [, assetType, name] = urlSegments;
-
- if (assetType === 'jsons') {
- const filePath = path.join(opts.outDir, 'jsons', name);
- if (!fsSync.existsSync(filePath)) {
- res.writeHead(404, 'No such json file');
- res.end();
- return;
- }
-
- const readStream = fsSync.createReadStream(filePath);
- readStream.on('data', (data) => res.write(data));
- readStream.on('end', () => {
- res.writeHead(200);
- res.end();
- });
- readStream.on('error', (err) => {
- res.writeHead(500, `Error Occurred: ${err}`);
- res.end();
- });
- } else if (assetType === 'tabs') {
- const proxyReq = http.request({
- host: '127.0.0.2',
- port: tabsPort,
- path: req.url,
- method: req.method,
- headers: req.headers,
- }, (proxyRes) => {
- // Forward each incoming request to esbuild
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res, { end: true });
- });
- // Forward the body of the request to esbuild
- req.pipe(proxyReq, { end: true });
- } else if (assetType === 'bundles') {
- const proxyReq = http.request({
- host: '127.0.0.2',
- port: bundlesPort,
- path: req.url,
- method: req.method,
- headers: req.headers,
- }, (proxyRes) => {
- // Forward each incoming request to esbuild
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res, { end: true });
- });
- // Forward the body of the request to esbuild
- req.pipe(proxyReq, { end: true });
- } else {
- res.writeHead(400);
- res.end();
- }
- }
- });
- httpServer.listen(opts.port, opts.ip);
-
- await new Promise((resolve) => httpServer.once('listening', () => resolve()));
- console.log(`${
- chalk.greenBright(`Serving ${
- chalk.cyanBright(`./${opts.outDir}`)
- } at`)} ${
- chalk.yellowBright(`${opts.ip}:${opts.port}`)
- }`);
- }
-
- await waitForQuit();
-
- if (httpServer) {
- httpServer.close();
- }
-
- await Promise.all([
- bundlesContext.cancel()
- .then(() => bundlesContext.dispose()),
- tabsContext.cancel()
- .then(() => tabsContext.dispose()),
- ]);
-
- let app: Application | null = null;
- if (opts.docs) {
- ({ result: [app] } = await initTypedoc({
- srcDir: opts.srcDir,
- bundles: Object.keys(manifest),
- verbose,
- }, true));
- }
-
- let typedocProj: ProjectReflection | null = null;
- const buildDocs = async () => {
- if (!opts.docs) return [];
- typedocProj = app.convert();
- return buildJsons(typedocProj, {
- bundles: Object.keys(manifest),
- outDir: opts.outDir,
- });
- };
-
- if (shouldWatch) {
- console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes`));
- await context.watch();
- }
-
- if (shouldServe) {
- const { port: servePort, host: serveHost } = await context.serve({
- servedir: opts.outDir,
- port: opts.port || 8022,
- host: opts.ip || '0.0.0.0',
- onRequest: ({ method, path: urlPath, remoteAddress, timeInMS }) => console.log(`[${new Date()
- .toISOString()}] ${chalk.gray(remoteAddress)} "${chalk.cyan(`${method} ${urlPath}`)}": Response Time: ${
- chalk.magentaBright(`${divideAndRound(timeInMS, 1000, 2)}s`)}`),
- });
- console.log(`${
- chalk.greenBright(`Serving ${
- chalk.cyanBright(`./${opts.outDir}`)
- } at`)} ${
- chalk.yellowBright(`${serveHost}:${servePort}`)
- }`);
- }
-
- console.log(chalk.yellowBright('Press CTRL + C to stop'));
-
- await waitForQuit();
- console.log(chalk.yellowBright('Stopping...'));
- const [htmlResult] = await Promise.all([
- opts.docs
- ? buildHtml(app, typedocProj, {
- outDir: opts.outDir,
- modulesSpecified: false,
- })
- : Promise.resolve(null),
- context.cancel(),
- ]);
-
- logHtmlResult(htmlResult);
- await context.dispose();
- });
-
-export default devCommand;
-*/
+import chalk from 'chalk';
+import { context as esbuild } from 'esbuild';
+import lodash from 'lodash';
+import type { Application } from 'typedoc';
+
+import { waitForQuit } from '../scriptUtils.js';
+
+import { buildHtml, buildJsons, initTypedoc, logHtmlResult } from './docs/index.js';
+import { getBundleOptions, reduceBundleOutputFiles } from './modules/bundle.js';
+import { getTabOptions, reduceTabOutputFiles } from './modules/tab.js';
+import {
+ copyManifest,
+ createBuildCommand,
+ createBuildDirs,
+ divideAndRound,
+ logResult,
+ retrieveBundlesAndTabs,
+} from './buildUtils.js';
+import type { BuildCommandInputs, UnreducedResult } from './types.js';
+
+type ContextOptions = Record<'srcDir' | 'outDir', string>;
+const getBundleContext = (options: ContextOptions, bundles: string[], app?: Application) => esbuild({
+ ...getBundleOptions(bundles, options),
+ plugins: [{
+ name: 'Bundle Compiler',
+ async setup(pluginBuild) {
+ let jsonPromise: Promise | null = null;
+ if (app) {
+ app.convertAndWatch(async (project) => {
+ console.log(chalk.magentaBright('Beginning jsons build...'));
+ jsonPromise = buildJsons(project, {
+ outDir: options.outDir,
+ bundles,
+ });
+ });
+ }
+
+ let startTime: number;
+ pluginBuild.onStart(() => {
+ console.log(chalk.magentaBright('Beginning bundles build...'));
+ startTime = performance.now();
+ });
+
+ pluginBuild.onEnd(async ({ outputFiles }) => {
+ const [mainResults, jsonResults] = await Promise.all([
+ reduceBundleOutputFiles(outputFiles, startTime, options.outDir),
+ jsonPromise || Promise.resolve([]),
+ ]);
+ logResult(mainResults.concat(jsonResults), false);
+
+ console.log(chalk.gray(`Bundles took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
+ });
+ },
+ }],
+});
+
+const getTabContext = (options: ContextOptions, tabs: string[]) => esbuild(lodash.merge({
+ plugins: [{
+ name: 'Tab Compiler',
+ setup(pluginBuild) {
+ let startTime: number;
+ pluginBuild.onStart(() => {
+ console.log(chalk.magentaBright('Beginning tabs build...'));
+ startTime = performance.now();
+ });
+
+ pluginBuild.onEnd(async ({ outputFiles }) => {
+ const mainResults = await reduceTabOutputFiles(outputFiles, startTime, options.outDir);
+ logResult(mainResults, false);
+
+ console.log(chalk.gray(`Tabs took ${divideAndRound(performance.now() - startTime, 1000, 2)}s to complete\n`));
+ });
+ },
+ }],
+}, getTabOptions(tabs, options)));
+
+// const serveContext = async (context: Awaited>) => {
+// const { port } = await context.serve({
+// host: '127.0.0.2',
+// onRequest: ({ method, path: urlPath, remoteAddress, timeInMS }) => console.log(`[${new Date()
+// .toISOString()}] ${chalk.gray(remoteAddress)} "${chalk.cyan(`${method} ${urlPath}`)}": Response Time: ${
+// chalk.magentaBright(`${divideAndRound(timeInMS, 1000, 2)}s`)}`),
+// });
+
+// return port;
+// };
+
+type WatchCommandInputs = {
+ docs: boolean;
+} & BuildCommandInputs;
+
+export const watchCommand = createBuildCommand('watch', false)
+ .description('Run esbuild in watch mode, rebuilding on every detected file system change')
+ .option('--no-docs', 'Don\'t rebuild documentation')
+ .action(async (opts: WatchCommandInputs) => {
+ const [{ bundles, tabs }] = await Promise.all([
+ retrieveBundlesAndTabs(opts.manifest, null, null),
+ createBuildDirs(opts.outDir),
+ copyManifest(opts),
+ ]);
+
+ let app: Application | null = null;
+ if (opts.docs) {
+ ({ result: [app] } = await initTypedoc({
+ srcDir: opts.srcDir,
+ bundles,
+ verbose: false,
+ }, true));
+ }
+
+ const [bundlesContext, tabsContext] = await Promise.all([
+ getBundleContext(opts, bundles, app),
+ getTabContext(opts, tabs),
+ ]);
+
+ console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes\nPress CTRL + C to stop`));
+ await Promise.all([bundlesContext.watch(), tabsContext.watch()]);
+ await waitForQuit();
+ console.log(chalk.yellowBright('Stopping...'));
+
+ const htmlPromise = !opts.docs
+ ? Promise.resolve(null)
+ : app.convert()
+ .then((proj) => buildHtml(app, proj, {
+ outDir: opts.outDir,
+ modulesSpecified: false,
+ }));
+
+ const [htmlResult] = await Promise.all([
+ htmlPromise,
+ bundlesContext.cancel()
+ .then(() => bundlesContext.dispose()),
+ tabsContext.cancel()
+ .then(() => tabsContext.dispose()),
+ copyManifest(opts),
+ ]);
+ logHtmlResult(htmlResult);
+ });
+
+/*
+type DevCommandInputs = {
+ docs: boolean;
+
+ ip: string | null;
+ port: number | null;
+
+ watch: boolean;
+ serve: boolean;
+} & BuildCommandInputs;
+
+const devCommand = createBuildCommand('dev')
+ .description('Use this command to leverage esbuild\'s automatic rebuilding capapbilities.'
+ + ' Use --watch to rebuild every time the file system detects changes and'
+ + ' --serve to serve modules using a special HTTP server that rebuilds on each request.'
+ + ' If neither is specified then --serve is assumed')
+ .option('--no-docs', 'Don\'t rebuild documentation')
+ .option('-w, --watch', 'Rebuild on file system changes', false)
+ .option('-s, --serve', 'Run the HTTP server, and rebuild on every request', false)
+ .option('-i, --ip', 'Host interface to bind to', null)
+ .option('-p, --port', 'Port to bind for the server to bind to', (value) => {
+ const parsedInt = parseInt(value);
+ if (isNaN(parsedInt) || parsedInt < 1 || parsedInt > 65535) {
+ throw new InvalidArgumentError(`Expected port to be a valid number between 1-65535, got ${value}!`);
+ }
+ return parsedInt;
+ }, null)
+ .action(async ({ verbose, ...opts }: DevCommandInputs) => {
+ const shouldWatch = opts.watch;
+ const shouldServe = opts.serve || !opts.watch;
+
+ if (!shouldServe) {
+ if (opts.ip) console.log(chalk.yellowBright('--ip option specified without --serve!'));
+ if (opts.port) console.log(chalk.yellowBright('--port option specified without --serve!'));
+ }
+
+ const [{ bundles, tabs }] = await Promise.all([
+ retrieveBundlesAndTabs(opts.manifest, null, null),
+ fsPromises.mkdir(`${opts.outDir}/bundles/`, { recursive: true }),
+ fsPromises.mkdir(`${opts.outDir}/tabs/`, { recursive: true }),
+ fsPromises.mkdir(`${opts.outDir}/jsons/`, { recursive: true }),
+ fsPromises.copyFile(opts.manifest, `${opts.outDir}/${opts.manifest}`),
+ ]);
+
+
+ const [bundlesContext, tabsContext] = await Promise.all([
+ getBundleContext(opts, bundles),
+ getTabContext(opts, tabs),
+ ]);
+
+ await Promise.all([
+ bundlesContext.watch(),
+ tabsContext.watch(),
+ ]);
+
+ await Promise.all([
+ bundlesContext.cancel()
+ .then(() => bundlesContext.dispose()),
+ tabsContext.cancel()
+ .then(() => tabsContext.dispose()),
+ ]);
+
+ await waitForQuit();
+
+
+ if (opts.watch) {
+ await Promise.all([
+ bundlesContext.watch(),
+ tabsContext.watch(),
+ ]);
+ }
+
+ let httpServer: http.Server | null = null;
+ if (opts.serve) {
+ const [bundlesPort, tabsPort] = await Promise.all([
+ serveContext(bundlesContext),
+ serveContext(tabsContext),
+ ]);
+
+ httpServer = http.createServer((req, res) => {
+ const urlSegments = req.url.split('/');
+ if (urlSegments.length === 3) {
+ const [, assetType, name] = urlSegments;
+
+ if (assetType === 'jsons') {
+ const filePath = path.join(opts.outDir, 'jsons', name);
+ if (!fsSync.existsSync(filePath)) {
+ res.writeHead(404, 'No such json file');
+ res.end();
+ return;
+ }
+
+ const readStream = fsSync.createReadStream(filePath);
+ readStream.on('data', (data) => res.write(data));
+ readStream.on('end', () => {
+ res.writeHead(200);
+ res.end();
+ });
+ readStream.on('error', (err) => {
+ res.writeHead(500, `Error Occurred: ${err}`);
+ res.end();
+ });
+ } else if (assetType === 'tabs') {
+ const proxyReq = http.request({
+ host: '127.0.0.2',
+ port: tabsPort,
+ path: req.url,
+ method: req.method,
+ headers: req.headers,
+ }, (proxyRes) => {
+ // Forward each incoming request to esbuild
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res, { end: true });
+ });
+ // Forward the body of the request to esbuild
+ req.pipe(proxyReq, { end: true });
+ } else if (assetType === 'bundles') {
+ const proxyReq = http.request({
+ host: '127.0.0.2',
+ port: bundlesPort,
+ path: req.url,
+ method: req.method,
+ headers: req.headers,
+ }, (proxyRes) => {
+ // Forward each incoming request to esbuild
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res, { end: true });
+ });
+ // Forward the body of the request to esbuild
+ req.pipe(proxyReq, { end: true });
+ } else {
+ res.writeHead(400);
+ res.end();
+ }
+ }
+ });
+ httpServer.listen(opts.port, opts.ip);
+
+ await new Promise((resolve) => httpServer.once('listening', () => resolve()));
+ console.log(`${
+ chalk.greenBright(`Serving ${
+ chalk.cyanBright(`./${opts.outDir}`)
+ } at`)} ${
+ chalk.yellowBright(`${opts.ip}:${opts.port}`)
+ }`);
+ }
+
+ await waitForQuit();
+
+ if (httpServer) {
+ httpServer.close();
+ }
+
+ await Promise.all([
+ bundlesContext.cancel()
+ .then(() => bundlesContext.dispose()),
+ tabsContext.cancel()
+ .then(() => tabsContext.dispose()),
+ ]);
+
+ let app: Application | null = null;
+ if (opts.docs) {
+ ({ result: [app] } = await initTypedoc({
+ srcDir: opts.srcDir,
+ bundles: Object.keys(manifest),
+ verbose,
+ }, true));
+ }
+
+ let typedocProj: ProjectReflection | null = null;
+ const buildDocs = async () => {
+ if (!opts.docs) return [];
+ typedocProj = app.convert();
+ return buildJsons(typedocProj, {
+ bundles: Object.keys(manifest),
+ outDir: opts.outDir,
+ });
+ };
+
+ if (shouldWatch) {
+ console.log(chalk.yellowBright(`Watching ${chalk.cyanBright(`./${opts.srcDir}`)} for changes`));
+ await context.watch();
+ }
+
+ if (shouldServe) {
+ const { port: servePort, host: serveHost } = await context.serve({
+ servedir: opts.outDir,
+ port: opts.port || 8022,
+ host: opts.ip || '0.0.0.0',
+ onRequest: ({ method, path: urlPath, remoteAddress, timeInMS }) => console.log(`[${new Date()
+ .toISOString()}] ${chalk.gray(remoteAddress)} "${chalk.cyan(`${method} ${urlPath}`)}": Response Time: ${
+ chalk.magentaBright(`${divideAndRound(timeInMS, 1000, 2)}s`)}`),
+ });
+ console.log(`${
+ chalk.greenBright(`Serving ${
+ chalk.cyanBright(`./${opts.outDir}`)
+ } at`)} ${
+ chalk.yellowBright(`${serveHost}:${servePort}`)
+ }`);
+ }
+
+ console.log(chalk.yellowBright('Press CTRL + C to stop'));
+
+ await waitForQuit();
+ console.log(chalk.yellowBright('Stopping...'));
+ const [htmlResult] = await Promise.all([
+ opts.docs
+ ? buildHtml(app, typedocProj, {
+ outDir: opts.outDir,
+ modulesSpecified: false,
+ })
+ : Promise.resolve(null),
+ context.cancel(),
+ ]);
+
+ logHtmlResult(htmlResult);
+ await context.dispose();
+ });
+
+export default devCommand;
+*/
diff --git a/scripts/src/build/docs/__mocks__/docUtils.ts b/scripts/src/build/docs/__mocks__/docUtils.ts
index 7cc4e51d6..7f755a72c 100644
--- a/scripts/src/build/docs/__mocks__/docUtils.ts
+++ b/scripts/src/build/docs/__mocks__/docUtils.ts
@@ -1,21 +1,21 @@
-import type { ProjectReference } from 'typescript';
-
-export const initTypedoc = jest.fn(() => {
- const proj = {
- getChildByName: () => ({
- children: [],
- }),
- path: '',
- } as ProjectReference;
-
- return Promise.resolve({
- elapsed: 0,
- result: [{
- convert: jest.fn()
- .mockReturnValue(proj),
- generateDocs: jest.fn(() => Promise.resolve()),
- }, proj],
- });
-});
-
+import type { ProjectReference } from 'typescript';
+
+export const initTypedoc = jest.fn(() => {
+ const proj = {
+ getChildByName: () => ({
+ children: [],
+ }),
+ path: '',
+ } as ProjectReference;
+
+ return Promise.resolve({
+ elapsed: 0,
+ result: [{
+ convert: jest.fn()
+ .mockReturnValue(proj),
+ generateDocs: jest.fn(() => Promise.resolve()),
+ }, proj],
+ });
+});
+
export const logTypedocTime = jest.fn();
\ No newline at end of file
diff --git a/scripts/src/build/docs/__tests__/docs.test.ts b/scripts/src/build/docs/__tests__/docs.test.ts
index 2a9e4950d..9f743dba0 100644
--- a/scripts/src/build/docs/__tests__/docs.test.ts
+++ b/scripts/src/build/docs/__tests__/docs.test.ts
@@ -1,90 +1,90 @@
-import type { MockedFunction } from 'jest-mock';
-import { getBuildDocsCommand } from '..';
-import { initTypedoc } from '../docUtils';
-import * as jsonModule from '../json';
-import * as htmlModule from '../html';
-import fs from 'fs/promises';
-
-jest.mock('../../prebuild/tsc');
-
-jest.spyOn(jsonModule, 'buildJsons');
-jest.spyOn(htmlModule, 'buildHtml');
-
-const asMock = any>(func: T) => func as MockedFunction;
-const mockBuildJson = asMock(jsonModule.buildJsons);
-
-const runCommand = (...args: string[]) => getBuildDocsCommand().parseAsync(args, { from: 'user' });
-describe('test the docs command', () => {
- it('should create the output directories and call all doc build functions', async () => {
- await runCommand();
-
- expect(fs.mkdir)
- .toBeCalledWith('build', { recursive: true })
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- expect(htmlModule.buildHtml)
- .toHaveBeenCalledTimes(1);
-
- expect(initTypedoc)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should only build the documentation for specified modules', async () => {
- await runCommand('test0', 'test1')
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- const buildJsonCall = mockBuildJson.mock.calls[0];
- expect(buildJsonCall[1])
- .toMatchObject({
- outDir: 'build',
- bundles: ['test0', 'test1']
- })
-
- expect(htmlModule.buildHtml)
- .toHaveBeenCalledTimes(1);
-
- expect(htmlModule.buildHtml)
- .toReturnWith(Promise.resolve({
- elapsed: 0,
- result: {
- severity: 'warn'
- }
- }))
- });
-
- it('should exit with code 1 if tsc returns with an error', async () => {
- try {
- await runCommand('--tsc');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(0);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it("should exit with code 1 when there are errors", async () => {
- mockBuildJson.mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- })
-});
+import type { MockedFunction } from 'jest-mock';
+import { getBuildDocsCommand } from '..';
+import { initTypedoc } from '../docUtils';
+import * as jsonModule from '../json';
+import * as htmlModule from '../html';
+import fs from 'fs/promises';
+
+jest.mock('../../prebuild/tsc');
+
+jest.spyOn(jsonModule, 'buildJsons');
+jest.spyOn(htmlModule, 'buildHtml');
+
+const asMock = any>(func: T) => func as MockedFunction;
+const mockBuildJson = asMock(jsonModule.buildJsons);
+
+const runCommand = (...args: string[]) => getBuildDocsCommand().parseAsync(args, { from: 'user' });
+describe('test the docs command', () => {
+ it('should create the output directories and call all doc build functions', async () => {
+ await runCommand();
+
+ expect(fs.mkdir)
+ .toBeCalledWith('build', { recursive: true })
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ expect(htmlModule.buildHtml)
+ .toHaveBeenCalledTimes(1);
+
+ expect(initTypedoc)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should only build the documentation for specified modules', async () => {
+ await runCommand('test0', 'test1')
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ const buildJsonCall = mockBuildJson.mock.calls[0];
+ expect(buildJsonCall[1])
+ .toMatchObject({
+ outDir: 'build',
+ bundles: ['test0', 'test1']
+ })
+
+ expect(htmlModule.buildHtml)
+ .toHaveBeenCalledTimes(1);
+
+ expect(htmlModule.buildHtml)
+ .toReturnWith(Promise.resolve({
+ elapsed: 0,
+ result: {
+ severity: 'warn'
+ }
+ }))
+ });
+
+ it('should exit with code 1 if tsc returns with an error', async () => {
+ try {
+ await runCommand('--tsc');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(0);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it("should exit with code 1 when there are errors", async () => {
+ mockBuildJson.mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ })
+});
diff --git a/scripts/src/build/docs/__tests__/json.test.ts b/scripts/src/build/docs/__tests__/json.test.ts
index 9e983c2fb..9ae4f595a 100644
--- a/scripts/src/build/docs/__tests__/json.test.ts
+++ b/scripts/src/build/docs/__tests__/json.test.ts
@@ -1,246 +1,246 @@
-import type { MockedFunction } from "jest-mock";
-import getJsonCommand, * as jsonModule from '../json';
-import * as tscModule from '../../prebuild/tsc';
-import fs from 'fs/promises';
-import type { DeclarationReflection } from "typedoc";
-
-jest.spyOn(jsonModule, 'buildJsons');
-jest.spyOn(tscModule, 'runTsc')
- .mockResolvedValue({
- elapsed: 0,
- result: {
- severity: 'error',
- results: [],
- }
- })
-
-const mockBuildJson = jsonModule.buildJsons as MockedFunction;
-const runCommand = (...args: string[]) => getJsonCommand().parseAsync(args, { from: 'user' });
-
-describe('test json command', () => {
- test('normal function', async () => {
- await runCommand();
-
- expect(fs.mkdir)
- .toBeCalledWith('build', { recursive: true })
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
- })
-
- it('should only build the jsons for specified modules', async () => {
- await runCommand('test0', 'test1')
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- const buildJsonCall = mockBuildJson.mock.calls[0];
- expect(buildJsonCall[1])
- .toMatchObject({
- outDir: 'build',
- bundles: ['test0', 'test1']
- })
- });
-
- it('should exit with code 1 if tsc returns with an error', async () => {
- try {
- await runCommand('--tsc');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(0);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it('should exit with code 1 if buildJsons returns with an error', async () => {
- mockBuildJson.mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(jsonModule.buildJsons)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- })
-});
-
-describe('test parsers', () => {
- const { Variable: variableParser, Function: functionParser } = jsonModule.parsers;
-
- describe('test function parser', () => {
- test('normal function with parameters', () => {
- const element = {
- name: 'foo',
- signatures: [{
- parameters: [{
- name: 'x',
- type: {
- name: 'number',
- },
- }, {
- name: 'y',
- type: {
- name: 'string',
- },
- }],
- type: {
- name: 'string',
- },
- comment: {
- summary: [{
- text: 'Test'
- }, {
- text: ' Description'
- }]
- }
- }]
- } as DeclarationReflection;
-
- const { header, desc } = functionParser(element);
-
- expect(header)
- .toEqual(`${element.name}(x: number, y: string) → {string}`);
-
- expect(desc)
- .toEqual('Test Description
');
- });
-
- test('normal function without parameters', () => {
- const element = {
- name: 'foo',
- signatures: [{
- type: {
- name: 'string',
- },
- comment: {
- summary: [{
- text: 'Test'
- }, {
- text: ' Description'
- }]
- }
- }]
- } as DeclarationReflection;
-
- const { header, desc } = functionParser(element);
-
- expect(header)
- .toEqual(`${element.name}() → {string}`);
-
- expect(desc)
- .toEqual('Test Description
');
- });
-
- test('normal function without return type', () => {
- const element = {
- name: 'foo',
- signatures: [{
- comment: {
- summary: [{
- text: 'Test'
- }, {
- text: ' Description'
- }]
- }
- }]
- } as DeclarationReflection;
-
- const { header, desc } = functionParser(element);
-
- expect(header)
- .toEqual(`${element.name}() → {void}`);
-
- expect(desc)
- .toEqual('Test Description
');
- });
-
- it('should provide \'No description available\' when description is missing', () => {
- const element = {
- name: 'foo',
- signatures: [{}]
- } as DeclarationReflection;
-
- const { header, desc } = functionParser(element);
-
- expect(header)
- .toEqual(`${element.name}() → {void}`);
-
- expect(desc)
- .toEqual('No description available
');
- });
- });
-
- describe('test variable parser', () => {
- test('normal function', () => {
- const element = {
- name: 'test_variable',
- type: {
- name: 'number'
- },
- comment: {
- summary: [{
- text: 'Test'
- }, {
- text: ' Description'
- }]
- }
- } as DeclarationReflection;
-
- const { header, desc } = variableParser(element);
-
- expect(header)
- .toEqual(`${element.name}: number`);
-
- expect(desc)
- .toEqual('Test Description
');
- })
-
- it('should provide \'No description available\' when description is missing', () => {
- const element = {
- name: 'test_variable',
- type: {
- name: 'number'
- },
- } as DeclarationReflection;
-
- const { header, desc } = variableParser(element);
-
- expect(header)
- .toEqual(`${element.name}: number`);
-
- expect(desc)
- .toEqual('No description available
');
- })
-
- it("should provide 'unknown' if type information is unavailable", () => {
- const element = {
- name: 'test_variable',
- comment: {
- summary: [{
- text: 'Test'
- }, {
- text: 'Description'
- }]
- }
- } as DeclarationReflection;
-
- const { header, desc } = variableParser(element);
-
- expect(header)
- .toEqual(`${element.name}: unknown`);
-
- expect(desc)
- .toEqual('TestDescription
');
- });
- });
-});
+import type { MockedFunction } from "jest-mock";
+import getJsonCommand, * as jsonModule from '../json';
+import * as tscModule from '../../prebuild/tsc';
+import fs from 'fs/promises';
+import { ReflectionKind, type DeclarationReflection } from "typedoc";
+
+jest.spyOn(jsonModule, 'buildJsons');
+jest.spyOn(tscModule, 'runTsc')
+ .mockResolvedValue({
+ elapsed: 0,
+ result: {
+ severity: 'error',
+ results: [],
+ }
+ })
+
+const mockBuildJson = jsonModule.buildJsons as MockedFunction;
+const runCommand = (...args: string[]) => getJsonCommand().parseAsync(args, { from: 'user' });
+
+describe('test json command', () => {
+ test('normal function', async () => {
+ await runCommand();
+
+ expect(fs.mkdir)
+ .toBeCalledWith('build', { recursive: true })
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+ })
+
+ it('should only build the jsons for specified modules', async () => {
+ await runCommand('test0', 'test1')
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ const buildJsonCall = mockBuildJson.mock.calls[0];
+ expect(buildJsonCall[1])
+ .toMatchObject({
+ outDir: 'build',
+ bundles: ['test0', 'test1']
+ })
+ });
+
+ it('should exit with code 1 if tsc returns with an error', async () => {
+ try {
+ await runCommand('--tsc');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(0);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it('should exit with code 1 if buildJsons returns with an error', async () => {
+ mockBuildJson.mockResolvedValueOnce([['json', 'test0', { severity: 'error' }]])
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(jsonModule.buildJsons)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ })
+});
+
+describe('test parsers', () => {
+ const { [ReflectionKind.Variable]: variableParser, [ReflectionKind.Function]: functionParser } = jsonModule.parsers;
+
+ describe('test function parser', () => {
+ test('normal function with parameters', () => {
+ const element = {
+ name: 'foo',
+ signatures: [{
+ parameters: [{
+ name: 'x',
+ type: {
+ name: 'number',
+ },
+ }, {
+ name: 'y',
+ type: {
+ name: 'string',
+ },
+ }],
+ type: {
+ name: 'string',
+ },
+ comment: {
+ summary: [{
+ text: 'Test'
+ }, {
+ text: ' Description'
+ }]
+ }
+ }]
+ } as DeclarationReflection;
+
+ const { header, desc } = functionParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}(x: number, y: string) → {string}`);
+
+ expect(desc)
+ .toEqual('Test Description
');
+ });
+
+ test('normal function without parameters', () => {
+ const element = {
+ name: 'foo',
+ signatures: [{
+ type: {
+ name: 'string',
+ },
+ comment: {
+ summary: [{
+ text: 'Test'
+ }, {
+ text: ' Description'
+ }]
+ }
+ }]
+ } as DeclarationReflection;
+
+ const { header, desc } = functionParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}() → {string}`);
+
+ expect(desc)
+ .toEqual('Test Description
');
+ });
+
+ test('normal function without return type', () => {
+ const element = {
+ name: 'foo',
+ signatures: [{
+ comment: {
+ summary: [{
+ text: 'Test'
+ }, {
+ text: ' Description'
+ }]
+ }
+ }]
+ } as DeclarationReflection;
+
+ const { header, desc } = functionParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}() → {void}`);
+
+ expect(desc)
+ .toEqual('Test Description
');
+ });
+
+ it('should provide \'No description available\' when description is missing', () => {
+ const element = {
+ name: 'foo',
+ signatures: [{}]
+ } as DeclarationReflection;
+
+ const { header, desc } = functionParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}() → {void}`);
+
+ expect(desc)
+ .toEqual('No description available
');
+ });
+ });
+
+ describe('test variable parser', () => {
+ test('normal function', () => {
+ const element = {
+ name: 'test_variable',
+ type: {
+ name: 'number'
+ },
+ comment: {
+ summary: [{
+ text: 'Test'
+ }, {
+ text: ' Description'
+ }]
+ }
+ } as DeclarationReflection;
+
+ const { header, desc } = variableParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}: number`);
+
+ expect(desc)
+ .toEqual('Test Description
');
+ })
+
+ it('should provide \'No description available\' when description is missing', () => {
+ const element = {
+ name: 'test_variable',
+ type: {
+ name: 'number'
+ },
+ } as DeclarationReflection;
+
+ const { header, desc } = variableParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}: number`);
+
+ expect(desc)
+ .toEqual('No description available
');
+ })
+
+ it("should provide 'unknown' if type information is unavailable", () => {
+ const element = {
+ name: 'test_variable',
+ comment: {
+ summary: [{
+ text: 'Test'
+ }, {
+ text: 'Description'
+ }]
+ }
+ } as DeclarationReflection;
+
+ const { header, desc } = variableParser!(element);
+
+ expect(header)
+ .toEqual(`${element.name}: unknown`);
+
+ expect(desc)
+ .toEqual('TestDescription
');
+ });
+ });
+});
diff --git a/scripts/src/build/docs/docUtils.ts b/scripts/src/build/docs/docUtils.ts
index 3880ab46b..8cea83b39 100644
--- a/scripts/src/build/docs/docUtils.ts
+++ b/scripts/src/build/docs/docUtils.ts
@@ -1,56 +1,52 @@
-import chalk from 'chalk';
-import { type ProjectReflection, Application, TSConfigReader } from 'typedoc';
-
-import { wrapWithTimer } from '../../scriptUtils.js';
-import { bundleNameExpander, divideAndRound } from '../buildUtils.js';
-
-type TypedocOpts = {
- srcDir: string;
- bundles: string[];
- verbose: boolean;
-};
-
-/**
- * Offload running typedoc into async code to increase parallelism
- *
- * @param watch Pass true to initialize typedoc in watch mode. `app.convert()` will not be called.
- */
-export const initTypedoc = wrapWithTimer(
- ({
- srcDir,
- bundles,
- verbose,
- }: TypedocOpts,
- watch?: boolean) => new Promise<[Application, ProjectReflection]>((resolve, reject) => {
- try {
- const app = new Application();
- app.options.addReader(new TSConfigReader());
-
- app.bootstrap({
- categorizeByGroup: true,
- entryPoints: bundles.map(bundleNameExpander(srcDir)),
- excludeInternal: true,
- logger: watch ? 'none' : undefined,
- logLevel: verbose ? 'Info' : 'Error',
- name: 'Source Academy Modules',
- readme: `${srcDir}/README.md`,
- tsconfig: `${srcDir}/tsconfig.json`,
- skipErrorChecking: true,
- watch,
- });
-
- if (watch) resolve([app, null]);
-
- const project = app.convert();
- if (!project) {
- reject(new Error('Failed to initialize typedoc - Make sure to check that the source files have no compilation errors!'));
- } else resolve([app, project]);
- } catch (error) {
- reject(error);
- }
- }),
-);
-
-export const logTypedocTime = (elapsed: number) => console.log(
- `${chalk.cyanBright('Took')} ${divideAndRound(elapsed, 1000)}s ${chalk.cyanBright('to initialize typedoc')}`,
-);
+import chalk from 'chalk';
+import { type ProjectReflection, Application, TSConfigReader } from 'typedoc';
+
+import { wrapWithTimer } from '../../scriptUtils.js';
+import { bundleNameExpander, divideAndRound } from '../buildUtils.js';
+
+type TypedocOpts = {
+ srcDir: string;
+ bundles: string[];
+ verbose: boolean;
+};
+
+/**
+ * Typedoc initialization: Use this to get an instance of typedoc which can then be used to
+ * generate both json and html documentation
+ *
+ * @param watch Pass true to initialize typedoc in watch mode. `app.convert()` will not be called.
+ */
+export const initTypedoc = wrapWithTimer(
+ async ({
+ srcDir,
+ bundles,
+ verbose,
+ }: TypedocOpts,
+ watch?: boolean): Promise<[Application, ProjectReflection | null]> => {
+ const app = await Application.bootstrap({
+ categorizeByGroup: true,
+ entryPoints: bundles.map(bundleNameExpander(srcDir)),
+ excludeInternal: true,
+ // logger: watch ? 'none' : undefined,
+ logLevel: verbose ? 'Info' : 'Error',
+ name: 'Source Academy Modules',
+ readme: './scripts/src/build/docs/docsreadme.md',
+ tsconfig: `${srcDir}/tsconfig.json`,
+ skipErrorChecking: true,
+ watch,
+ });
+
+ app.options.addReader(new TSConfigReader());
+
+ if (watch) return [app, null];
+
+ const project = await app.convert();
+ if (!project) {
+ throw new Error('Failed to initialize typedoc - Make sure to check that the source files have no compilation errors!');
+ } else return [app, project];
+ },
+);
+
+export const logTypedocTime = (elapsed: number) => console.log(
+ `${chalk.cyanBright('Took')} ${divideAndRound(elapsed, 1000)}s ${chalk.cyanBright('to initialize typedoc')}`,
+);
diff --git a/scripts/src/build/docs/docsreadme.md b/scripts/src/build/docs/docsreadme.md
new file mode 100644
index 000000000..0adf8f5be
--- /dev/null
+++ b/scripts/src/build/docs/docsreadme.md
@@ -0,0 +1,18 @@
+# Overview
+
+The Source Academy allows programmers to import functions and constants from a module, using JavaScript's `import` directive. For example, the programmer may decide to import the function `thrice` from the module `repeat` by starting the program with
+```
+import { thrice } from "repeat";
+```
+
+When evaluating such a directive, the Source Academy looks for a module with the matching name, here `repeat`, in a preconfigured modules site. The Source Academy at https://sourceacademy.org uses the default modules site (located at https://source-academy.github.io/modules).
+
+After importing functions or constants from a module, they can be used as usual.
+```
+thrice(display)(8); // displays 8 three times
+```
+if `thrice` is declared in the module `repeat` as follows:
+```
+const thrice = f => x => f(f(f(x)));
+```
+[List of modules](modules.html) available at the default modules site.
\ No newline at end of file
diff --git a/scripts/src/build/docs/html.ts b/scripts/src/build/docs/html.ts
index 735ae76c7..5ae55a9ab 100644
--- a/scripts/src/build/docs/html.ts
+++ b/scripts/src/build/docs/html.ts
@@ -1,101 +1,101 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import type { Application, ProjectReflection } from 'typedoc';
-
-import { wrapWithTimer } from '../../scriptUtils.js';
-import { divideAndRound, exitOnError, retrieveBundles } from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import type { BuildCommandInputs, OperationResult } from '../types';
-
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-
-type HTMLOptions = {
- outDir: string;
- modulesSpecified: boolean;
-};
-
-/**
- * Build HTML documentation
- */
-export const buildHtml = wrapWithTimer(async (app: Application,
- project: ProjectReflection, {
- outDir,
- modulesSpecified,
- }: HTMLOptions): Promise => {
- if (modulesSpecified) {
- return {
- severity: 'warn',
- };
- }
-
- try {
- await app.generateDocs(project, `${outDir}/documentation`);
- return {
- severity: 'success',
- };
- } catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-});
-
-/**
- * Log output from `buildHtml`
- * @see {buildHtml}
- */
-export const logHtmlResult = (htmlResult: Awaited> | null) => {
- if (!htmlResult) return;
-
- const { elapsed, result: { severity, error } } = htmlResult;
- if (severity === 'success') {
- const timeStr = divideAndRound(elapsed, 1000);
- console.log(`${chalk.cyanBright('HTML documentation built')} ${chalk.greenBright('successfully')} in ${timeStr}s\n`);
- } else if (severity === 'warn') {
- console.log(chalk.yellowBright('Modules were manually specified, not building HTML documentation\n'));
- } else {
- console.log(`${chalk.cyanBright('HTML documentation')} ${chalk.redBright('failed')}: ${error}\n`);
- }
-};
-
-type HTMLCommandInputs = Omit;
-
-/**
- * Get CLI command to only build HTML documentation
- */
-const getBuildHtmlCommand = () => new Command('html')
- .option('--outDir ', 'Output directory', 'build')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-v, --verbose', 'Display more information about the build results', false)
- .option('--tsc', 'Run tsc before building')
- .description('Build only HTML documentation')
- .action(async (opts: HTMLCommandInputs) => {
- const bundles = await retrieveBundles(opts.manifest, null);
-
- if (opts.tsc) {
- const tscResult = await runTsc(opts.srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error') process.exit(1);
- }
-
- const { elapsed: typedoctime, result: [app, project] } = await initTypedoc({
- bundles,
- srcDir: opts.srcDir,
- verbose: opts.verbose,
- });
- logTypedocTime(typedoctime);
-
- const htmlResult = await buildHtml(app, project, {
- outDir: opts.outDir,
- modulesSpecified: false,
- });
- logHtmlResult(htmlResult);
- exitOnError([], htmlResult.result);
- });
-
-export default getBuildHtmlCommand;
+import chalk from 'chalk';
+import { Command } from 'commander';
+import type { Application, ProjectReflection } from 'typedoc';
+
+import { wrapWithTimer } from '../../scriptUtils.js';
+import { divideAndRound, exitOnError, retrieveBundles } from '../buildUtils.js';
+import { logTscResults, runTsc } from '../prebuild/tsc.js';
+import type { BuildCommandInputs, OperationResult } from '../types';
+
+import { initTypedoc, logTypedocTime } from './docUtils.js';
+
+type HTMLOptions = {
+ outDir: string;
+ modulesSpecified: boolean;
+};
+
+/**
+ * Build HTML documentation
+ */
+export const buildHtml = wrapWithTimer(async (app: Application,
+ project: ProjectReflection, {
+ outDir,
+ modulesSpecified,
+ }: HTMLOptions): Promise => {
+ if (modulesSpecified) {
+ return {
+ severity: 'warn',
+ };
+ }
+
+ try {
+ await app.generateDocs(project, `${outDir}/documentation`);
+ return {
+ severity: 'success',
+ };
+ } catch (error) {
+ return {
+ severity: 'error',
+ error,
+ };
+ }
+});
+
+/**
+ * Log output from `buildHtml`
+ * @see {buildHtml}
+ */
+export const logHtmlResult = (htmlResult: Awaited> | null) => {
+ if (!htmlResult) return;
+
+ const { elapsed, result: { severity, error } } = htmlResult;
+ if (severity === 'success') {
+ const timeStr = divideAndRound(elapsed, 1000);
+ console.log(`${chalk.cyanBright('HTML documentation built')} ${chalk.greenBright('successfully')} in ${timeStr}s\n`);
+ } else if (severity === 'warn') {
+ console.log(chalk.yellowBright('Modules were manually specified, not building HTML documentation\n'));
+ } else {
+ console.log(`${chalk.cyanBright('HTML documentation')} ${chalk.redBright('failed')}: ${error}\n`);
+ }
+};
+
+type HTMLCommandInputs = Omit;
+
+/**
+ * Get CLI command to only build HTML documentation
+ */
+const getBuildHtmlCommand = () => new Command('html')
+ .option('--outDir ', 'Output directory', 'build')
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .option('-v, --verbose', 'Display more information about the build results', false)
+ .option('--tsc', 'Run tsc before building')
+ .description('Build only HTML documentation')
+ .action(async (opts: HTMLCommandInputs) => {
+ const bundles = await retrieveBundles(opts.manifest, null);
+
+ if (opts.tsc) {
+ const tscResult = await runTsc(opts.srcDir, {
+ bundles,
+ tabs: [],
+ });
+ logTscResults(tscResult);
+ if (tscResult.result.severity === 'error') process.exit(1);
+ }
+
+ const { elapsed: typedoctime, result: [app, project] } = await initTypedoc({
+ bundles,
+ srcDir: opts.srcDir,
+ verbose: opts.verbose,
+ });
+ logTypedocTime(typedoctime);
+
+ const htmlResult = await buildHtml(app, project, {
+ outDir: opts.outDir,
+ modulesSpecified: false,
+ });
+ logHtmlResult(htmlResult);
+ exitOnError([], htmlResult.result);
+ });
+
+export default getBuildHtmlCommand;
diff --git a/scripts/src/build/docs/index.ts b/scripts/src/build/docs/index.ts
index 2797e39b4..ad790be47 100644
--- a/scripts/src/build/docs/index.ts
+++ b/scripts/src/build/docs/index.ts
@@ -1,62 +1,62 @@
-import chalk from 'chalk';
-
-import { printList } from '../../scriptUtils.js';
-import { createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundles } from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import type { BuildCommandInputs } from '../types.js';
-
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-import { buildHtml, logHtmlResult } from './html.js';
-import { buildJsons } from './json.js';
-
-export const getBuildDocsCommand = () => createBuildCommand('docs', true)
- .argument('[modules...]', 'Manually specify which modules to build documentation', null)
- .action(async (modules: string[] | null, { manifest, srcDir, outDir, verbose, tsc }: Omit) => {
- const [bundles] = await Promise.all([
- retrieveBundles(manifest, modules),
- createOutDir(outDir),
- ]);
-
- if (bundles.length === 0) return;
-
- if (tsc) {
- const tscResult = await runTsc(srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error') process.exit(1);
- }
-
- printList(`${chalk.cyanBright('Building HTML documentation and jsons for the following bundles:')}\n`, bundles);
-
- const { elapsed, result: [app, project] } = await initTypedoc({
- bundles,
- srcDir,
- verbose,
- });
- const [jsonResults, htmlResult] = await Promise.all([
- buildJsons(project, {
- outDir,
- bundles,
- }),
- buildHtml(app, project, {
- outDir,
- modulesSpecified: modules !== null,
- }),
- // app.generateJson(project, `${buildOpts.outDir}/docs.json`),
- ]);
-
- logTypedocTime(elapsed);
- if (!jsonResults && !htmlResult) return;
-
- logHtmlResult(htmlResult);
- logResult(jsonResults, verbose);
- exitOnError(jsonResults, htmlResult.result);
- })
- .description('Build only jsons and HTML documentation');
-
-export default getBuildDocsCommand;
-export { default as getBuildHtmlCommand, logHtmlResult, buildHtml } from './html.js';
-export { default as getBuildJsonCommand, buildJsons } from './json.js';
-export { initTypedoc } from './docUtils.js';
+import chalk from 'chalk';
+
+import { printList } from '../../scriptUtils.js';
+import { createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundles } from '../buildUtils.js';
+import { logTscResults, runTsc } from '../prebuild/tsc.js';
+import type { BuildCommandInputs } from '../types.js';
+
+import { initTypedoc, logTypedocTime } from './docUtils.js';
+import { buildHtml, logHtmlResult } from './html.js';
+import { buildJsons } from './json.js';
+
+export const getBuildDocsCommand = () => createBuildCommand('docs', true)
+ .argument('[modules...]', 'Manually specify which modules to build documentation', null)
+ .action(async (modules: string[] | null, { manifest, srcDir, outDir, verbose, tsc }: Omit) => {
+ const [bundles] = await Promise.all([
+ retrieveBundles(manifest, modules),
+ createOutDir(outDir),
+ ]);
+
+ if (bundles.length === 0) return;
+
+ if (tsc) {
+ const tscResult = await runTsc(srcDir, {
+ bundles,
+ tabs: [],
+ });
+ logTscResults(tscResult);
+ if (tscResult.result.severity === 'error') process.exit(1);
+ }
+
+ printList(`${chalk.cyanBright('Building HTML documentation and jsons for the following bundles:')}\n`, bundles);
+
+ const { elapsed, result: [app, project] } = await initTypedoc({
+ bundles,
+ srcDir,
+ verbose,
+ });
+ const [jsonResults, htmlResult] = await Promise.all([
+ buildJsons(project, {
+ outDir,
+ bundles,
+ }),
+ buildHtml(app, project, {
+ outDir,
+ modulesSpecified: modules !== null,
+ }),
+ // app.generateJson(project, `${buildOpts.outDir}/docs.json`),
+ ]);
+
+ logTypedocTime(elapsed);
+ if (!jsonResults && !htmlResult) return;
+
+ logHtmlResult(htmlResult);
+ logResult(jsonResults, verbose);
+ exitOnError(jsonResults, htmlResult.result);
+ })
+ .description('Build only jsons and HTML documentation');
+
+export default getBuildDocsCommand;
+export { default as getBuildHtmlCommand, logHtmlResult, buildHtml } from './html.js';
+export { default as getBuildJsonCommand, buildJsons } from './json.js';
+export { initTypedoc } from './docUtils.js';
diff --git a/scripts/src/build/docs/json.ts b/scripts/src/build/docs/json.ts
index d82d64cb2..3e85f3cb0 100644
--- a/scripts/src/build/docs/json.ts
+++ b/scripts/src/build/docs/json.ts
@@ -1,237 +1,242 @@
-import chalk from 'chalk';
-import fs from 'fs/promises';
-import type {
- DeclarationReflection,
- IntrinsicType,
- ProjectReflection,
- ReferenceType,
- SomeType,
-} from 'typedoc';
-
-import { printList, wrapWithTimer } from '../../scriptUtils.js';
-import {
- createBuildCommand,
- createOutDir,
- exitOnError,
- logResult,
- retrieveBundles,
-} from '../buildUtils.js';
-import { logTscResults, runTsc } from '../prebuild/tsc.js';
-import type { BuildCommandInputs, BuildResult, Severity, UnreducedResult } from '../types';
-
-import { initTypedoc, logTypedocTime } from './docUtils.js';
-import drawdown from './drawdown.js';
-
-
-const typeToName = (type?: SomeType, alt: string = 'unknown') => (type ? (type as ReferenceType | IntrinsicType).name : alt);
-
-/**
- * Parsers to convert typedoc elements into strings
- */
-export const parsers: Record Record<'header' | 'desc', string>> = {
- Variable(element) {
- let desc: string;
- if (!element.comment) desc = 'No description available';
- else {
- desc = element.comment.summary.map(({ text }) => text)
- .join('');
- }
- return {
- header: `${element.name}: ${typeToName(element.type)}`,
- desc: drawdown(desc),
- };
- },
- Function({ name: elementName, signatures: [signature] }) {
- // Form the parameter string for the function
- let paramStr: string;
- if (!signature.parameters) paramStr = '()';
- else {
- paramStr = `(${signature.parameters
- .map(({ type, name }) => {
- const typeStr = typeToName(type);
- return `${name}: ${typeStr}`;
- })
- .join(', ')})`;
- }
- const resultStr = typeToName(signature.type, 'void');
- let desc: string;
- if (!signature.comment) desc = 'No description available';
- else {
- desc = signature.comment.summary.map(({ text }) => text)
- .join('');
- }
- return {
- header: `${elementName}${paramStr} → {${resultStr}}`,
- desc: drawdown(desc),
- };
- },
-};
-
-/**
- * Build a single json
- */
-const buildJson = wrapWithTimer(async (
- bundle: string,
- moduleDocs: DeclarationReflection | undefined,
- outDir: string,
-): Promise => {
- try {
- if (!moduleDocs) {
- return {
- severity: 'error',
- error: `Could not find generated docs for ${bundle}`,
- };
- }
-
- const [sevRes, result] = moduleDocs.children.reduce(([{ severity, errors }, decls], decl) => {
- try {
- const parser = parsers[decl.kindString];
- if (!parser) {
- return [{
- severity: 'warn' as Severity,
- errors: [...errors, `Symbol '${decl.name}': Could not find parser for type ${decl.kindString}`],
- }, decls];
- }
- const { header, desc } = parser(decl);
-
- return [{
- severity,
- errors,
- }, {
- ...decls,
- [decl.name]: ``,
-
- }];
- } catch (error) {
- return [{
- severity: 'warn' as Severity,
- errors: [...errors, `Could not parse declaration for ${decl.name}: ${error}`],
- }];
- }
- }, [
- {
- severity: 'success',
- errors: [],
- },
- {},
- ] as [
- {
- severity: Severity,
- errors: any[]
- },
- Record,
- // Record,
- ]);
-
- let size: number | undefined;
- if (result) {
- const outFile = `${outDir}/jsons/${bundle}.json`;
- await fs.writeFile(outFile, JSON.stringify(result, null, 2));
- ({ size } = await fs.stat(outFile));
- } else {
- if (sevRes.severity !== 'error') sevRes.severity = 'warn';
- sevRes.errors.push(`No json generated for ${bundle}`);
- }
-
- const errorStr = sevRes.errors.length > 1 ? `${sevRes.errors[0]} +${sevRes.errors.length - 1}` : sevRes.errors[0];
-
- return {
- severity: sevRes.severity,
- fileSize: size,
- error: errorStr,
- };
- } catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-});
-
-type BuildJsonOpts = {
- bundles: string[];
- outDir: string;
-};
-
-/**
- * Build all specified jsons
- */
-export const buildJsons = async (project: ProjectReflection, { outDir, bundles }: BuildJsonOpts): Promise => {
- await fs.mkdir(`${outDir}/jsons`, { recursive: true });
- if (bundles.length === 1) {
- // If only 1 bundle is provided, typedoc's output is different in structure
- // So this new parser is used instead.
- const [bundle] = bundles;
- const { elapsed, result } = await buildJson(bundle, project as any, outDir);
- return [['json', bundle, {
- ...result,
- elapsed,
- }] as UnreducedResult];
- }
-
- return Promise.all(
- bundles.map(async (bundle) => {
- const { elapsed, result } = await buildJson(bundle, project.getChildByName(bundle) as DeclarationReflection, outDir);
- return ['json', bundle, {
- ...result,
- elapsed,
- }] as UnreducedResult;
- }),
- );
-};
-
-/**
- * Get console command for building jsons
- *
- */
-const getJsonCommand = () => createBuildCommand('jsons', false)
- .option('--tsc', 'Run tsc before building')
- .argument('[modules...]', 'Manually specify which modules to build jsons for', null)
- .action(async (modules: string[] | null, { manifest, srcDir, outDir, verbose, tsc }: Omit) => {
- const [bundles] = await Promise.all([
- retrieveBundles(manifest, modules),
- createOutDir(outDir),
- ]);
-
- if (bundles.length === 0) return;
-
- if (tsc) {
- const tscResult = await runTsc(srcDir, {
- bundles,
- tabs: [],
- });
- logTscResults(tscResult);
- if (tscResult.result.severity === 'error') process.exit(1);
- }
-
- const { elapsed: typedocTime, result: [, project] } = await initTypedoc({
- bundles,
- srcDir,
- verbose,
- });
-
-
- logTypedocTime(typedocTime);
- printList(chalk.magentaBright('Building jsons for the following modules:\n'), bundles);
- const jsonResults = await buildJsons(project, {
- bundles,
- outDir,
- });
- logResult(jsonResults, verbose);
- exitOnError(jsonResults);
- })
- .description('Build only jsons');
-
-export default getJsonCommand;
+import chalk from 'chalk';
+import fs from 'fs/promises';
+import {
+ type DeclarationReflection,
+ type IntrinsicType,
+ type ProjectReflection,
+ type ReferenceType,
+ type SomeType,
+ ReflectionKind,
+} from 'typedoc';
+
+import { printList, wrapWithTimer } from '../../scriptUtils.js';
+import {
+ createBuildCommand,
+ createOutDir,
+ exitOnError,
+ logResult,
+ retrieveBundles,
+} from '../buildUtils.js';
+import { logTscResults, runTsc } from '../prebuild/tsc.js';
+import type { BuildCommandInputs, BuildResult, Severity, UnreducedResult } from '../types';
+
+import { initTypedoc, logTypedocTime } from './docUtils.js';
+import drawdown from './drawdown.js';
+
+
+const typeToName = (type?: SomeType, alt: string = 'unknown') => (type ? (type as ReferenceType | IntrinsicType).name : alt);
+
+type ReflectionParser = (docs: DeclarationReflection) => Record<'header' | 'desc', string>;
+type ReflectionParsers = Partial>;
+
+/**
+ * Parsers to convert typedoc elements into strings
+ */
+export const parsers: ReflectionParsers = {
+ [ReflectionKind.Variable](element) {
+ let desc: string;
+ if (!element.comment) desc = 'No description available';
+ else {
+ desc = element.comment.summary.map(({ text }) => text)
+ .join('');
+ }
+ return {
+ header: `${element.name}: ${typeToName(element.type)}`,
+ desc: drawdown(desc),
+ };
+ },
+ [ReflectionKind.Function]({ name: elementName, signatures: [signature] }) {
+ // Form the parameter string for the function
+ let paramStr: string;
+ if (!signature.parameters) paramStr = '()';
+ else {
+ paramStr = `(${signature.parameters
+ .map(({ type, name }) => {
+ const typeStr = typeToName(type);
+ return `${name}: ${typeStr}`;
+ })
+ .join(', ')})`;
+ }
+ const resultStr = typeToName(signature.type, 'void');
+ let desc: string;
+ if (!signature.comment) desc = 'No description available';
+ else {
+ desc = signature.comment.summary.map(({ text }) => text)
+ .join('');
+ }
+ return {
+ header: `${elementName}${paramStr} → {${resultStr}}`,
+ desc: drawdown(desc),
+ };
+ },
+};
+
+/**
+ * Build a single json
+ */
+const buildJson = wrapWithTimer(async (
+ bundle: string,
+ moduleDocs: DeclarationReflection | undefined,
+ outDir: string,
+): Promise => {
+ try {
+ if (!moduleDocs) {
+ return {
+ severity: 'error',
+ error: `Could not find generated docs for ${bundle}`,
+ };
+ }
+
+ const [sevRes, result] = moduleDocs.children.reduce(([{ severity, errors }, decls], decl) => {
+ try {
+ const parser = parsers[decl.kind];
+ if (!parser) {
+ return [{
+ severity: 'warn' as Severity,
+ errors: [...errors, `Symbol '${decl.name}': Could not find parser for type ${decl.getFriendlyFullName()}`],
+ }, decls];
+ }
+ const { header, desc } = parser(decl);
+
+ return [{
+ severity,
+ errors,
+ }, {
+ ...decls,
+ [decl.name]: ``,
+
+ }];
+ } catch (error) {
+ return [{
+ severity: 'warn' as Severity,
+ errors: [...errors, `Could not parse declaration for ${decl.name}: ${error}`],
+ }];
+ }
+ }, [
+ {
+ severity: 'success',
+ errors: [],
+ },
+ {},
+ ] as [
+ {
+ severity: Severity,
+ errors: any[]
+ },
+ Record,
+ // Record,
+ ]);
+
+ let size: number | undefined;
+ if (result) {
+ const outFile = `${outDir}/jsons/${bundle}.json`;
+ await fs.writeFile(outFile, JSON.stringify(result, null, 2));
+ ({ size } = await fs.stat(outFile));
+ } else {
+ if (sevRes.severity !== 'error') sevRes.severity = 'warn';
+ sevRes.errors.push(`No json generated for ${bundle}`);
+ }
+
+ const errorStr = sevRes.errors.length > 1 ? `${sevRes.errors[0]} +${sevRes.errors.length - 1}` : sevRes.errors[0];
+
+ return {
+ severity: sevRes.severity,
+ fileSize: size,
+ error: errorStr,
+ };
+ } catch (error) {
+ return {
+ severity: 'error',
+ error,
+ };
+ }
+});
+
+type BuildJsonOpts = {
+ bundles: string[];
+ outDir: string;
+};
+
+/**
+ * Build all specified jsons
+ */
+export const buildJsons = async (project: ProjectReflection, { outDir, bundles }: BuildJsonOpts): Promise => {
+ await fs.mkdir(`${outDir}/jsons`, { recursive: true });
+ if (bundles.length === 1) {
+ // If only 1 bundle is provided, typedoc's output is different in structure
+ // So this new parser is used instead.
+ const [bundle] = bundles;
+ const { elapsed, result } = await buildJson(bundle, project as any, outDir);
+ return [['json', bundle, {
+ ...result,
+ elapsed,
+ }] as UnreducedResult];
+ }
+
+ return Promise.all(
+ bundles.map(async (bundle) => {
+ const { elapsed, result } = await buildJson(bundle, project.getChildByName(bundle) as DeclarationReflection, outDir);
+ return ['json', bundle, {
+ ...result,
+ elapsed,
+ }] as UnreducedResult;
+ }),
+ );
+};
+
+/**
+ * Get console command for building jsons
+ *
+ */
+const getJsonCommand = () => createBuildCommand('jsons', false)
+ .option('--tsc', 'Run tsc before building')
+ .argument('[modules...]', 'Manually specify which modules to build jsons for', null)
+ .action(async (modules: string[] | null, { manifest, srcDir, outDir, verbose, tsc }: Omit) => {
+ const [bundles] = await Promise.all([
+ retrieveBundles(manifest, modules),
+ createOutDir(outDir),
+ ]);
+
+ if (bundles.length === 0) return;
+
+ if (tsc) {
+ const tscResult = await runTsc(srcDir, {
+ bundles,
+ tabs: [],
+ });
+ logTscResults(tscResult);
+ if (tscResult.result.severity === 'error') process.exit(1);
+ }
+
+ const { elapsed: typedocTime, result: [, project] } = await initTypedoc({
+ bundles,
+ srcDir,
+ verbose,
+ });
+
+
+ logTypedocTime(typedocTime);
+ printList(chalk.magentaBright('Building jsons for the following modules:\n'), bundles);
+ const jsonResults = await buildJsons(project, {
+ bundles,
+ outDir,
+ });
+
+ logResult(jsonResults, verbose);
+ exitOnError(jsonResults);
+ })
+ .description('Build only jsons');
+
+export default getJsonCommand;
diff --git a/scripts/src/build/index.ts b/scripts/src/build/index.ts
index 1c3371d99..24c587e59 100644
--- a/scripts/src/build/index.ts
+++ b/scripts/src/build/index.ts
@@ -1,80 +1,80 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-
-import { printList } from '../scriptUtils.js';
-
-import { logTypedocTime } from './docs/docUtils.js';
-import getBuildDocsCommand, {
- buildHtml,
- buildJsons,
- getBuildHtmlCommand,
- getBuildJsonCommand,
- initTypedoc,
- logHtmlResult,
-} from './docs/index.js';
-import getBuildModulesCommand, {
- buildModules,
- getBuildTabsCommand,
-} from './modules/index.js';
-import type { LintCommandInputs } from './prebuild/eslint.js';
-import { prebuild } from './prebuild/index.js';
-import { copyManifest, createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundlesAndTabs } from './buildUtils.js';
-import type { BuildCommandInputs } from './types.js';
-
-export const getBuildAllCommand = () => createBuildCommand('all', true)
- .argument('[modules...]', 'Manually specify which modules to build', null)
- .action(async (modules: string[] | null, opts: BuildCommandInputs & LintCommandInputs) => {
- const [assets] = await Promise.all([
- retrieveBundlesAndTabs(opts.manifest, modules, null),
- createOutDir(opts.outDir),
- ]);
- await prebuild(opts, assets);
-
- printList(`${chalk.cyanBright('Building bundles, tabs, jsons and HTML for the following bundles:')}\n`, assets.bundles);
-
- const [results, {
- typedoctime,
- html: htmlResult,
- json: jsonResults,
- }] = await Promise.all([
- buildModules(opts, assets),
- initTypedoc({
- ...opts,
- bundles: assets.bundles,
- })
- .then(async ({ elapsed, result: [app, project] }) => {
- const [json, html] = await Promise.all([
- buildJsons(project, {
- outDir: opts.outDir,
- bundles: assets.bundles,
- }),
- buildHtml(app, project, {
- outDir: opts.outDir,
- modulesSpecified: modules !== null,
- }),
- ]);
- return {
- json,
- html,
- typedoctime: elapsed,
- };
- }),
- copyManifest(opts),
- ]);
-
- logTypedocTime(typedoctime);
-
- logResult(results.concat(jsonResults), opts.verbose);
- logHtmlResult(htmlResult);
- exitOnError(results, ...jsonResults, htmlResult.result);
- })
- .description('Build bundles, tabs, jsons and HTML documentation');
-
-export default new Command('build')
- .description('Run without arguments to build all, or use a specific build subcommand')
- .addCommand(getBuildAllCommand(), { isDefault: true })
- .addCommand(getBuildDocsCommand())
- .addCommand(getBuildHtmlCommand())
- .addCommand(getBuildJsonCommand())
- .addCommand(getBuildModulesCommand())
- .addCommand(getBuildTabsCommand());
+import chalk from 'chalk';
+import { Command } from 'commander';
+
+import { printList } from '../scriptUtils.js';
+
+import { logTypedocTime } from './docs/docUtils.js';
+import getBuildDocsCommand, {
+ buildHtml,
+ buildJsons,
+ getBuildHtmlCommand,
+ getBuildJsonCommand,
+ initTypedoc,
+ logHtmlResult,
+} from './docs/index.js';
+import getBuildModulesCommand, {
+ buildModules,
+ getBuildTabsCommand,
+} from './modules/index.js';
+import { prebuild } from './prebuild/index.js';
+import type { LintCommandInputs } from './prebuild/lint.js';
+import { copyManifest, createBuildCommand, createOutDir, exitOnError, logResult, retrieveBundlesAndTabs } from './buildUtils.js';
+import type { BuildCommandInputs } from './types.js';
+
+export const getBuildAllCommand = () => createBuildCommand('all', true)
+ .argument('[modules...]', 'Manually specify which modules to build', null)
+ .action(async (modules: string[] | null, opts: BuildCommandInputs & LintCommandInputs) => {
+ const [assets] = await Promise.all([
+ retrieveBundlesAndTabs(opts.manifest, modules, null),
+ createOutDir(opts.outDir),
+ ]);
+ await prebuild(opts, assets);
+
+ printList(`${chalk.cyanBright('Building bundles, tabs, jsons and HTML for the following bundles:')}\n`, assets.bundles);
+
+ const [results, {
+ typedoctime,
+ html: htmlResult,
+ json: jsonResults,
+ }] = await Promise.all([
+ buildModules(opts, assets),
+ initTypedoc({
+ ...opts,
+ bundles: assets.bundles,
+ })
+ .then(async ({ elapsed, result: [app, project] }) => {
+ const [json, html] = await Promise.all([
+ buildJsons(project, {
+ outDir: opts.outDir,
+ bundles: assets.bundles,
+ }),
+ buildHtml(app, project, {
+ outDir: opts.outDir,
+ modulesSpecified: modules !== null,
+ }),
+ ]);
+ return {
+ json,
+ html,
+ typedoctime: elapsed,
+ };
+ }),
+ copyManifest(opts),
+ ]);
+
+ logTypedocTime(typedoctime);
+
+ logResult(results.concat(jsonResults), opts.verbose);
+ logHtmlResult(htmlResult);
+ exitOnError(results, ...jsonResults, htmlResult.result);
+ })
+ .description('Build bundles, tabs, jsons and HTML documentation');
+
+export default new Command('build')
+ .description('Run without arguments to build all, or use a specific build subcommand')
+ .addCommand(getBuildAllCommand(), { isDefault: true })
+ .addCommand(getBuildDocsCommand())
+ .addCommand(getBuildHtmlCommand())
+ .addCommand(getBuildJsonCommand())
+ .addCommand(getBuildModulesCommand())
+ .addCommand(getBuildTabsCommand());
diff --git a/scripts/src/build/modules/__tests__/bundle.test.ts b/scripts/src/build/modules/__tests__/bundle.test.ts
index 8576ff1eb..e3f0ff3f8 100644
--- a/scripts/src/build/modules/__tests__/bundle.test.ts
+++ b/scripts/src/build/modules/__tests__/bundle.test.ts
@@ -1,61 +1,61 @@
-import { build as esbuild } from 'esbuild';
-import fs from 'fs/promises';
-import { outputBundle } from '../bundle';
-import { esbuildOptions } from '../moduleUtils';
-
-const testBundle = `
- import context from 'js-slang/context';
-
- export const foo = () => 'foo';
- export const bar = () => {
- context.moduleContexts.test0.state = 'bar';
- };
-`
-
-test('building a bundle', async () => {
- const { outputFiles } = await esbuild({
- ...esbuildOptions,
- stdin: {
- contents: testBundle,
- },
- outdir: '.',
- outbase: '.',
- external: ['js-slang*'],
- });
-
- const [{ text: compiledBundle }] = outputFiles!;
-
- const result = await outputBundle('test0', compiledBundle, 'build');
- expect(result).toMatchObject({
- fileSize: 10,
- severity: 'success',
- })
-
- expect(fs.stat)
- .toHaveBeenCalledWith('build/bundles/test0.js')
-
- expect(fs.writeFile)
- .toHaveBeenCalledTimes(1)
-
- const call = (fs.writeFile as jest.MockedFunction).mock.calls[0];
-
- expect(call[0]).toEqual('build/bundles/test0.js')
- const bundleText = `(${call[1]})`;
- const mockContext = {
- moduleContexts: {
- test0: {
- state: null,
- }
- }
- }
- const bundleFuncs = eval(bundleText)(x => ({
- 'js-slang/context': mockContext,
- }[x]));
- expect(bundleFuncs.foo()).toEqual('foo');
- expect(bundleFuncs.bar()).toEqual(undefined);
- expect(mockContext.moduleContexts).toMatchObject({
- test0: {
- state: 'bar',
- },
- });
-});
+import { build as esbuild } from 'esbuild';
+import fs from 'fs/promises';
+import { outputBundle } from '../bundle';
+import { esbuildOptions } from '../moduleUtils';
+
+const testBundle = `
+ import context from 'js-slang/context';
+
+ export const foo = () => 'foo';
+ export const bar = () => {
+ context.moduleContexts.test0.state = 'bar';
+ };
+`
+
+test('building a bundle', async () => {
+ const { outputFiles } = await esbuild({
+ ...esbuildOptions,
+ stdin: {
+ contents: testBundle,
+ },
+ outdir: '.',
+ outbase: '.',
+ external: ['js-slang*'],
+ });
+
+ const [{ text: compiledBundle }] = outputFiles!;
+
+ const result = await outputBundle('test0', compiledBundle, 'build');
+ expect(result).toMatchObject({
+ fileSize: 10,
+ severity: 'success',
+ })
+
+ expect(fs.stat)
+ .toHaveBeenCalledWith('build/bundles/test0.js')
+
+ expect(fs.writeFile)
+ .toHaveBeenCalledTimes(1)
+
+ const call = (fs.writeFile as jest.MockedFunction).mock.calls[0];
+
+ expect(call[0]).toEqual('build/bundles/test0.js')
+ const bundleText = `(${call[1]})`;
+ const mockContext = {
+ moduleContexts: {
+ test0: {
+ state: null,
+ }
+ }
+ }
+ const bundleFuncs = eval(bundleText)(x => ({
+ 'js-slang/context': mockContext,
+ }[x]));
+ expect(bundleFuncs.foo()).toEqual('foo');
+ expect(bundleFuncs.bar()).toEqual(undefined);
+ expect(mockContext.moduleContexts).toMatchObject({
+ test0: {
+ state: 'bar',
+ },
+ });
+});
diff --git a/scripts/src/build/modules/__tests__/modules.test.ts b/scripts/src/build/modules/__tests__/modules.test.ts
index e97510804..302d0278e 100644
--- a/scripts/src/build/modules/__tests__/modules.test.ts
+++ b/scripts/src/build/modules/__tests__/modules.test.ts
@@ -1,56 +1,56 @@
-import getBuildModulesCommand, * as modules from '..';
-import fs from 'fs/promises';
-import pathlib from 'path';
-
-jest.spyOn(modules, 'buildModules');
-
-jest.mock('esbuild', () => ({
- build: jest.fn().mockResolvedValue({ outputFiles: [] }),
-}));
-
-jest.mock('../../prebuild/tsc');
-
-const runCommand = (...args: string[]) => getBuildModulesCommand().parseAsync(args, { from: 'user' });
-const buildModulesMock = modules.buildModules as jest.MockedFunction
-
-describe('test modules command', () => {
- it('should create the output directories, and copy the manifest', async () => {
- await runCommand();
-
- expect(fs.mkdir)
- .toBeCalledWith('build', { recursive: true })
-
- expect(fs.copyFile)
- .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
- })
-
- it('should only build specific modules and tabs when manually specified', async () => {
- await runCommand('test0');
-
- expect(modules.buildModules)
- .toHaveBeenCalledTimes(1);
-
- const buildModulesCall = buildModulesMock.mock.calls[0];
- expect(buildModulesCall[1])
- .toMatchObject({
- bundles: ['test0'],
- tabs: ['tab0'],
- modulesSpecified: true,
- })
- });
-
- it('should exit with code 1 if tsc returns with an error', async () => {
- try {
- await runCommand('--tsc');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'));
- }
-
- expect(modules.buildModules)
- .toHaveBeenCalledTimes(0);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
+import getBuildModulesCommand, * as modules from '..';
+import fs from 'fs/promises';
+import pathlib from 'path';
+
+jest.spyOn(modules, 'buildModules');
+
+jest.mock('esbuild', () => ({
+ build: jest.fn().mockResolvedValue({ outputFiles: [] }),
+}));
+
+jest.mock('../../prebuild/tsc');
+
+const runCommand = (...args: string[]) => getBuildModulesCommand().parseAsync(args, { from: 'user' });
+const buildModulesMock = modules.buildModules as jest.MockedFunction
+
+describe('test modules command', () => {
+ it('should create the output directories, and copy the manifest', async () => {
+ await runCommand();
+
+ expect(fs.mkdir)
+ .toBeCalledWith('build', { recursive: true })
+
+ expect(fs.copyFile)
+ .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
+ })
+
+ it('should only build specific modules and tabs when manually specified', async () => {
+ await runCommand('test0');
+
+ expect(modules.buildModules)
+ .toHaveBeenCalledTimes(1);
+
+ const buildModulesCall = buildModulesMock.mock.calls[0];
+ expect(buildModulesCall[1])
+ .toMatchObject({
+ bundles: ['test0'],
+ tabs: ['tab0'],
+ modulesSpecified: true,
+ })
+ });
+
+ it('should exit with code 1 if tsc returns with an error', async () => {
+ try {
+ await runCommand('--tsc');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'));
+ }
+
+ expect(modules.buildModules)
+ .toHaveBeenCalledTimes(0);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
})
\ No newline at end of file
diff --git a/scripts/src/build/modules/__tests__/tab.test.ts b/scripts/src/build/modules/__tests__/tab.test.ts
index 44337f696..e097393dd 100644
--- a/scripts/src/build/modules/__tests__/tab.test.ts
+++ b/scripts/src/build/modules/__tests__/tab.test.ts
@@ -1,34 +1,34 @@
-import getBuildTabsCommand, * as tabModule from '../tab';
-import fs from 'fs/promises';
-import pathlib from 'path';
-
-jest.spyOn(tabModule, 'buildTabs');
-
-jest.mock('esbuild', () => ({
- build: jest.fn().mockResolvedValue({ outputFiles: [] }),
-}));
-
-const runCommand = (...args: string[]) => getBuildTabsCommand().parseAsync(args, { from: 'user' });
-
-describe('test tab command', () => {
- it('should create the output directories, and copy the manifest', async () => {
- await runCommand();
-
- expect(fs.mkdir)
- .toBeCalledWith('build', { recursive: true })
-
- expect(fs.copyFile)
- .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
- })
-
- it('should only build specific tabs when manually specified', async () => {
- await runCommand('tab0');
-
- expect(tabModule.buildTabs)
- .toHaveBeenCalledTimes(1);
-
- const buildModulesCall = (tabModule.buildTabs as jest.MockedFunction).mock.calls[0];
- expect(buildModulesCall[0])
- .toEqual(['tab0']);
- });
+import getBuildTabsCommand, * as tabModule from '../tab';
+import fs from 'fs/promises';
+import pathlib from 'path';
+
+jest.spyOn(tabModule, 'buildTabs');
+
+jest.mock('esbuild', () => ({
+ build: jest.fn().mockResolvedValue({ outputFiles: [] }),
+}));
+
+const runCommand = (...args: string[]) => getBuildTabsCommand().parseAsync(args, { from: 'user' });
+
+describe('test tab command', () => {
+ it('should create the output directories, and copy the manifest', async () => {
+ await runCommand();
+
+ expect(fs.mkdir)
+ .toBeCalledWith('build', { recursive: true })
+
+ expect(fs.copyFile)
+ .toBeCalledWith('modules.json', pathlib.join('build', 'modules.json'));
+ })
+
+ it('should only build specific tabs when manually specified', async () => {
+ await runCommand('tab0');
+
+ expect(tabModule.buildTabs)
+ .toHaveBeenCalledTimes(1);
+
+ const buildModulesCall = (tabModule.buildTabs as jest.MockedFunction).mock.calls[0];
+ expect(buildModulesCall[0])
+ .toEqual(['tab0']);
+ });
});
\ No newline at end of file
diff --git a/scripts/src/build/modules/bundle.ts b/scripts/src/build/modules/bundle.ts
index 3c51d2e26..af17469c2 100644
--- a/scripts/src/build/modules/bundle.ts
+++ b/scripts/src/build/modules/bundle.ts
@@ -1,107 +1,133 @@
-import { parse } from 'acorn';
-import { generate } from 'astring';
-import {
- type BuildOptions as ESBuildOptions,
- type OutputFile,
- build as esbuild,
-} from 'esbuild';
-import type {
- ArrowFunctionExpression,
- BlockStatement,
- CallExpression,
- ExpressionStatement,
- Identifier,
- Program,
- VariableDeclaration,
-} from 'estree';
-import fs from 'fs/promises';
-import pathlib from 'path';
-
-import { bundleNameExpander } from '../buildUtils.js';
-import type { BuildOptions, BuildResult, UnreducedResult } from '../types.js';
-
-import { esbuildOptions } from './moduleUtils.js';
-
-export const outputBundle = async (name: string, bundleText: string, outDir: string): Promise> => {
- try {
- const parsed = parse(bundleText, { ecmaVersion: 6 }) as unknown as Program;
-
- // Account for 'use strict'; directives
- let declStatement: VariableDeclaration;
- if (parsed.body[0].type === 'VariableDeclaration') {
- declStatement = parsed.body[0];
- } else {
- declStatement = parsed.body[1] as unknown as VariableDeclaration;
- }
- const varDeclarator = declStatement.declarations[0];
- const callExpression = varDeclarator.init as CallExpression;
- const moduleCode = callExpression.callee as ArrowFunctionExpression;
-
- const output = {
- type: 'ArrowFunctionExpression',
- body: {
- type: 'BlockStatement',
- body: moduleCode.body.type === 'BlockStatement'
- ? (moduleCode.body as BlockStatement).body
- : [{
- type: 'ExpressionStatement',
- expression: moduleCode.body,
- } as ExpressionStatement],
- },
- params: [
- {
- type: 'Identifier',
- name: 'require',
- } as Identifier,
- ],
- } as ArrowFunctionExpression;
-
- let newCode = generate(output);
- if (newCode.endsWith(';')) newCode = newCode.slice(0, -1);
-
- const outFile = `${outDir}/bundles/${name}.js`;
- await fs.writeFile(outFile, newCode);
- const { size } = await fs.stat(outFile);
- return {
- severity: 'success',
- fileSize: size,
- };
- } catch (error) {
- console.log(error);
- return {
- severity: 'error',
- error,
- };
- }
-};
-
-export const bundleOptions: ESBuildOptions = {
- ...esbuildOptions,
- external: ['js-slang*'],
-};
-
-export const buildBundles = async (bundles: string[], { srcDir, outDir }: BuildOptions) => {
- const nameExpander = bundleNameExpander(srcDir);
- const { outputFiles } = await esbuild({
- ...bundleOptions,
- entryPoints: bundles.map(nameExpander),
- outbase: outDir,
- outdir: outDir,
- });
- return outputFiles;
-};
-
-export const reduceBundleOutputFiles = (outputFiles: OutputFile[], startTime: number, outDir: string) => Promise.all(outputFiles.map(async ({ path, text }) => {
- const [rawType, name] = path.split(pathlib.sep)
- .slice(-3, -1);
-
- if (rawType !== 'bundles') {
- throw new Error(`Expected only bundles, got ${rawType}`);
- }
-
- const result = await outputBundle(name, text, outDir);
- return ['bundle', name, {
- elapsed: performance.now() - startTime,
- ...result,
- }] as UnreducedResult;
-}));
+import { parse } from 'acorn';
+import { generate } from 'astring';
+import {
+ type BuildOptions as ESBuildOptions,
+ type OutputFile,
+ build as esbuild,
+} from 'esbuild';
+import type {
+ ArrowFunctionExpression,
+ CallExpression,
+ ExpressionStatement,
+ Identifier,
+ Program,
+ VariableDeclaration,
+} from 'estree';
+import fs from 'fs/promises';
+import pathlib from 'path';
+
+import { bundleNameExpander } from '../buildUtils.js';
+import type { BuildOptions, BuildResult, UnreducedResult } from '../types.js';
+
+import { esbuildOptions } from './moduleUtils.js';
+
+export const outputBundle = async (name: string, bundleText: string, outDir: string): Promise> => {
+ try {
+ const parsed = parse(bundleText, { ecmaVersion: 6 }) as unknown as Program;
+
+ // Account for 'use strict'; directives
+ let declStatement: VariableDeclaration;
+ if (parsed.body[0].type === 'VariableDeclaration') {
+ declStatement = parsed.body[0];
+ } else {
+ declStatement = parsed.body[1] as unknown as VariableDeclaration;
+ }
+ const varDeclarator = declStatement.declarations[0];
+ const callExpression = varDeclarator.init as CallExpression;
+ const moduleCode = callExpression.callee as ArrowFunctionExpression;
+
+ const output = {
+ type: 'ArrowFunctionExpression',
+ body: {
+ type: 'BlockStatement',
+ body: moduleCode.body.type === 'BlockStatement'
+ ? moduleCode.body.body
+ : [{
+ type: 'ExpressionStatement',
+ expression: moduleCode.body,
+ } as ExpressionStatement],
+ },
+ params: [
+ {
+ type: 'Identifier',
+ name: 'require',
+ } as Identifier,
+ ],
+ } as ArrowFunctionExpression;
+
+ let newCode = generate(output);
+ if (newCode.endsWith(';')) newCode = newCode.slice(0, -1);
+
+ const outFile = `${outDir}/bundles/${name}.js`;
+ await fs.writeFile(outFile, newCode);
+ const { size } = await fs.stat(outFile);
+ return {
+ severity: 'success',
+ fileSize: size,
+ };
+ } catch (error) {
+ console.log(error);
+ return {
+ severity: 'error',
+ error,
+ };
+ }
+};
+
+export const getBundleOptions = (bundles: string[], { srcDir, outDir }: Record<'srcDir' | 'outDir', string>): ESBuildOptions => {
+ const nameExpander = bundleNameExpander(srcDir);
+ return {
+ ...esbuildOptions,
+ entryPoints: bundles.map(nameExpander),
+ outbase: outDir,
+ outdir: outDir,
+ tsconfig: `${srcDir}/tsconfig.json`,
+ plugins: [{
+ name: 'Assert Polyfill',
+ setup(build) {
+ // Polyfill the NodeJS assert module
+ build.onResolve({ filter: /^assert/u }, () => ({
+ path: 'assert',
+ namespace: 'bundleAssert',
+ }));
+
+ build.onLoad({
+ filter: /^assert/u,
+ namespace: 'bundleAssert',
+ }, () => ({
+ contents: `
+ export default function assert(condition, message) {
+ if (condition) return;
+
+ if (typeof message === 'string' || message === undefined) {
+ throw new Error(message);
+ }
+
+ throw message;
+ }
+ `,
+ }));
+ },
+ }],
+ };
+};
+
+export const buildBundles = async (bundles: string[], options: BuildOptions) => {
+ const { outputFiles } = await esbuild(getBundleOptions(bundles, options));
+ return outputFiles;
+};
+
+export const reduceBundleOutputFiles = (outputFiles: OutputFile[], startTime: number, outDir: string) => Promise.all(outputFiles.map(async ({ path, text }) => {
+ const [rawType, name] = path.split(pathlib.sep)
+ .slice(-3, -1);
+
+ if (rawType !== 'bundles') {
+ throw new Error(`Expected only bundles, got ${rawType}`);
+ }
+
+ const result = await outputBundle(name, text, outDir);
+ return ['bundle', name, {
+ elapsed: performance.now() - startTime,
+ ...result,
+ }] as UnreducedResult;
+}));
diff --git a/scripts/src/build/modules/index.ts b/scripts/src/build/modules/index.ts
index 7c183e79e..dbbce2af5 100644
--- a/scripts/src/build/modules/index.ts
+++ b/scripts/src/build/modules/index.ts
@@ -1,68 +1,68 @@
-import chalk from 'chalk';
-import { promises as fs } from 'fs';
-
-import { printList } from '../../scriptUtils.js';
-import {
- copyManifest,
- createBuildCommand,
- createOutDir,
- exitOnError,
- logResult,
- retrieveBundlesAndTabs,
-} from '../buildUtils.js';
-import type { LintCommandInputs } from '../prebuild/eslint.js';
-import { prebuild } from '../prebuild/index.js';
-import type { AssetInfo, BuildCommandInputs, BuildOptions } from '../types';
-
-import { buildBundles, reduceBundleOutputFiles } from './bundle.js';
-import { buildTabs, reduceTabOutputFiles } from './tab.js';
-
-export const buildModules = async (opts: BuildOptions, { bundles, tabs }: AssetInfo) => {
- const startPromises: Promise[] = [];
- if (bundles.length > 0) {
- startPromises.push(fs.mkdir(`${opts.outDir}/bundles`, { recursive: true }));
- }
-
- if (tabs.length > 0) {
- startPromises.push(fs.mkdir(`${opts.outDir}/tabs`, { recursive: true }));
- }
-
- await Promise.all(startPromises);
- const startTime = performance.now();
- const [bundleResults, tabResults] = await Promise.all([
- buildBundles(bundles, opts)
- .then((outputFiles) => reduceBundleOutputFiles(outputFiles, startTime, opts.outDir)),
- buildTabs(tabs, opts)
- .then((outputFiles) => reduceTabOutputFiles(outputFiles, startTime, opts.outDir)),
- ]);
-
- return bundleResults.concat(tabResults);
-};
-
-const getBuildModulesCommand = () => createBuildCommand('modules', true)
- .argument('[modules...]', 'Manually specify which modules to build', null)
- .description('Build modules and their tabs')
- .action(async (modules: string[] | null, { manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
- const [assets] = await Promise.all([
- retrieveBundlesAndTabs(manifest, modules, null),
- createOutDir(opts.outDir),
- ]);
-
- await prebuild(opts, assets);
-
- printList(`${chalk.magentaBright('Building bundles and tabs for the following bundles:')}\n`, assets.bundles);
-
- const [results] = await Promise.all([
- buildModules(opts, assets),
- copyManifest({
- manifest,
- outDir: opts.outDir,
- }),
- ]);
- logResult(results, opts.verbose);
- exitOnError(results);
- })
- .description('Build only bundles and tabs');
-
-export { default as getBuildTabsCommand } from './tab.js';
-export default getBuildModulesCommand;
+import chalk from 'chalk';
+import { promises as fs } from 'fs';
+
+import { printList } from '../../scriptUtils.js';
+import {
+ copyManifest,
+ createBuildCommand,
+ createOutDir,
+ exitOnError,
+ logResult,
+ retrieveBundlesAndTabs,
+} from '../buildUtils.js';
+import { prebuild } from '../prebuild/index.js';
+import type { LintCommandInputs } from '../prebuild/lint.js';
+import type { AssetInfo, BuildCommandInputs, BuildOptions } from '../types';
+
+import { buildBundles, reduceBundleOutputFiles } from './bundle.js';
+import { buildTabs, reduceTabOutputFiles } from './tab.js';
+
+export const buildModules = async (opts: BuildOptions, { bundles, tabs }: AssetInfo) => {
+ const startPromises: Promise[] = [];
+ if (bundles.length > 0) {
+ startPromises.push(fs.mkdir(`${opts.outDir}/bundles`, { recursive: true }));
+ }
+
+ if (tabs.length > 0) {
+ startPromises.push(fs.mkdir(`${opts.outDir}/tabs`, { recursive: true }));
+ }
+
+ await Promise.all(startPromises);
+ const startTime = performance.now();
+ const [bundleResults, tabResults] = await Promise.all([
+ buildBundles(bundles, opts)
+ .then((outputFiles) => reduceBundleOutputFiles(outputFiles, startTime, opts.outDir)),
+ buildTabs(tabs, opts)
+ .then((outputFiles) => reduceTabOutputFiles(outputFiles, startTime, opts.outDir)),
+ ]);
+
+ return bundleResults.concat(tabResults);
+};
+
+const getBuildModulesCommand = () => createBuildCommand('modules', true)
+ .argument('[modules...]', 'Manually specify which modules to build', null)
+ .description('Build modules and their tabs')
+ .action(async (modules: string[] | null, { manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
+ const [assets] = await Promise.all([
+ retrieveBundlesAndTabs(manifest, modules, []),
+ createOutDir(opts.outDir),
+ ]);
+
+ await prebuild(opts, assets);
+
+ printList(`${chalk.magentaBright('Building bundles and tabs for the following bundles:')}\n`, assets.bundles);
+
+ const [results] = await Promise.all([
+ buildModules(opts, assets),
+ copyManifest({
+ manifest,
+ outDir: opts.outDir,
+ }),
+ ]);
+ logResult(results, opts.verbose);
+ exitOnError(results);
+ })
+ .description('Build only bundles and tabs');
+
+export { default as getBuildTabsCommand } from './tab.js';
+export default getBuildModulesCommand;
diff --git a/scripts/src/build/modules/moduleUtils.ts b/scripts/src/build/modules/moduleUtils.ts
index ee652dfdc..0d0eec5c0 100644
--- a/scripts/src/build/modules/moduleUtils.ts
+++ b/scripts/src/build/modules/moduleUtils.ts
@@ -1,155 +1,156 @@
-import type { BuildOptions as ESBuildOptions } from 'esbuild';
-import type {
- BinaryExpression,
- FunctionDeclaration,
- Identifier,
- IfStatement,
- Literal,
- MemberExpression,
- NewExpression,
- ObjectExpression,
- Property,
- ReturnStatement,
- TemplateLiteral,
- ThrowStatement,
- VariableDeclaration,
-} from 'estree';
-
-/**
- * Build the AST representation of a `require` function to use with the transpiled IIFEs
- */
-export const requireCreator = (createObj: Record) => ({
- type: 'FunctionDeclaration',
- id: {
- type: 'Identifier',
- name: 'require',
- } as Identifier,
- params: [
- {
- type: 'Identifier',
- name: 'x',
- } as Identifier,
- ],
- body: {
- type: 'BlockStatement',
- body: [
- {
- type: 'VariableDeclaration',
- kind: 'const',
- declarations: [
- {
- type: 'VariableDeclarator',
- id: {
- type: 'Identifier',
- name: 'result',
- } as Identifier,
- init: {
- type: 'MemberExpression',
- computed: true,
- property: {
- type: 'Identifier',
- name: 'x',
- } as Identifier,
- object: {
- type: 'ObjectExpression',
- properties: Object.entries(createObj)
- .map(([key, value]) => ({
- type: 'Property',
- kind: 'init',
- key: {
- type: 'Literal',
- value: key,
- } as Literal,
- value: {
- type: 'Identifier',
- name: value,
- } as Identifier,
- })) as Property[],
- } as ObjectExpression,
- } as MemberExpression,
- },
- ],
- } as VariableDeclaration,
- {
- type: 'IfStatement',
- test: {
- type: 'BinaryExpression',
- left: {
- type: 'Identifier',
- name: 'result',
- } as Identifier,
- operator: '===',
- right: {
- type: 'Identifier',
- name: 'undefined',
- } as Identifier,
- } as BinaryExpression,
- consequent: {
- type: 'ThrowStatement',
- argument: {
- type: 'NewExpression',
- callee: {
- type: 'Identifier',
- name: 'Error',
- } as Identifier,
- arguments: [
- {
- type: 'TemplateLiteral',
- expressions: [
- {
- type: 'Identifier',
- name: 'x',
- },
- ],
- quasis: [
- {
- type: 'TemplateElement',
- value: {
- raw: 'Internal Error: Unknown import "',
- },
- tail: false,
- },
- {
- type: 'TemplateElement',
- value: {
- raw: '"!',
- },
- tail: true,
- },
- ],
- } as TemplateLiteral,
- ],
- } as NewExpression,
- } as ThrowStatement,
- alternate: {
- type: 'ReturnStatement',
- argument: {
- type: 'Identifier',
- name: 'result',
- } as Identifier,
- } as ReturnStatement,
- } as IfStatement,
- ],
- },
-}) as FunctionDeclaration;
-
-export const esbuildOptions: ESBuildOptions = {
- bundle: true,
- format: 'iife',
- globalName: 'module',
- define: {
- process: JSON.stringify({
- env: {
- NODE_ENV: 'production',
- },
- }),
- },
- loader: {
- '.ts': 'ts',
- '.tsx': 'tsx',
- },
- // minify: true,
- platform: 'browser',
- target: 'es6',
- write: false,
-};
+import type { BuildOptions as ESBuildOptions } from 'esbuild';
+import type {
+ BinaryExpression,
+ FunctionDeclaration,
+ Identifier,
+ IfStatement,
+ Literal,
+ MemberExpression,
+ NewExpression,
+ ObjectExpression,
+ Property,
+ ReturnStatement,
+ TemplateLiteral,
+ ThrowStatement,
+ VariableDeclaration,
+} from 'estree';
+
+/**
+ * Build the AST representation of a `require` function to use with the transpiled IIFEs
+ */
+export const requireCreator = (createObj: Record) => ({
+ type: 'FunctionDeclaration',
+ id: {
+ type: 'Identifier',
+ name: 'require',
+ } as Identifier,
+ params: [
+ {
+ type: 'Identifier',
+ name: 'x',
+ } as Identifier,
+ ],
+ body: {
+ type: 'BlockStatement',
+ body: [
+ {
+ type: 'VariableDeclaration',
+ kind: 'const',
+ declarations: [
+ {
+ type: 'VariableDeclarator',
+ id: {
+ type: 'Identifier',
+ name: 'result',
+ } as Identifier,
+ init: {
+ type: 'MemberExpression',
+ computed: true,
+ property: {
+ type: 'Identifier',
+ name: 'x',
+ } as Identifier,
+ object: {
+ type: 'ObjectExpression',
+ properties: Object.entries(createObj)
+ .map(([key, value]) => ({
+ type: 'Property',
+ kind: 'init',
+ key: {
+ type: 'Literal',
+ value: key,
+ } as Literal,
+ value: {
+ type: 'Identifier',
+ name: value,
+ } as Identifier,
+ })) as Property[],
+ } as ObjectExpression,
+ } as MemberExpression,
+ },
+ ],
+ } as VariableDeclaration,
+ {
+ type: 'IfStatement',
+ test: {
+ type: 'BinaryExpression',
+ left: {
+ type: 'Identifier',
+ name: 'result',
+ } as Identifier,
+ operator: '===',
+ right: {
+ type: 'Identifier',
+ name: 'undefined',
+ } as Identifier,
+ } as BinaryExpression,
+ consequent: {
+ type: 'ThrowStatement',
+ argument: {
+ type: 'NewExpression',
+ callee: {
+ type: 'Identifier',
+ name: 'Error',
+ } as Identifier,
+ arguments: [
+ {
+ type: 'TemplateLiteral',
+ expressions: [
+ {
+ type: 'Identifier',
+ name: 'x',
+ },
+ ],
+ quasis: [
+ {
+ type: 'TemplateElement',
+ value: {
+ raw: 'Internal Error: Unknown import "',
+ },
+ tail: false,
+ },
+ {
+ type: 'TemplateElement',
+ value: {
+ raw: '"!',
+ },
+ tail: true,
+ },
+ ],
+ } as TemplateLiteral,
+ ],
+ } as NewExpression,
+ } as ThrowStatement,
+ alternate: {
+ type: 'ReturnStatement',
+ argument: {
+ type: 'Identifier',
+ name: 'result',
+ } as Identifier,
+ } as ReturnStatement,
+ } as IfStatement,
+ ],
+ },
+}) as FunctionDeclaration;
+
+export const esbuildOptions: ESBuildOptions = {
+ bundle: true,
+ format: 'iife',
+ globalName: 'module',
+ define: {
+ process: JSON.stringify({
+ env: {
+ NODE_ENV: 'production',
+ },
+ }),
+ },
+ loader: {
+ '.ts': 'ts',
+ '.tsx': 'tsx',
+ },
+ // minify: true,
+ platform: 'browser',
+ target: 'es6',
+ write: false,
+ external: ['js-slang*'],
+};
diff --git a/scripts/src/build/modules/tab.ts b/scripts/src/build/modules/tab.ts
index d987892dd..c11c6469b 100644
--- a/scripts/src/build/modules/tab.ts
+++ b/scripts/src/build/modules/tab.ts
@@ -1,133 +1,156 @@
-import { parse } from 'acorn';
-import { generate } from 'astring';
-import chalk from 'chalk';
-import {
- type BuildOptions as ESBuildOptions,
- type OutputFile,
- build as esbuild,
-} from 'esbuild';
-import type {
- ArrowFunctionExpression,
- Identifier,
- Literal,
- MemberExpression,
- Program,
- VariableDeclaration,
-} from 'estree';
-import fs from 'fs/promises';
-import pathlib from 'path';
-
-import { printList } from '../../scriptUtils.js';
-import {
- copyManifest,
- createBuildCommand,
- exitOnError,
- logResult,
- retrieveTabs,
- tabNameExpander,
-} from '../buildUtils.js';
-import type { LintCommandInputs } from '../prebuild/eslint.js';
-import { prebuild } from '../prebuild/index.js';
-import type { BuildCommandInputs, BuildOptions, BuildResult, UnreducedResult } from '../types';
-
-import { esbuildOptions } from './moduleUtils.js';
-
-const outputTab = async (tabName: string, text: string, outDir: string): Promise> => {
- try {
- const parsed = parse(text, { ecmaVersion: 6 }) as unknown as Program;
- const declStatement = parsed.body[1] as VariableDeclaration;
-
- const newTab = {
- type: 'ArrowFunctionExpression',
- body: {
- type: 'MemberExpression',
- object: declStatement.declarations[0].init,
- property: {
- type: 'Literal',
- value: 'default',
- } as Literal,
- computed: true,
- } as MemberExpression,
- params: [{
- type: 'Identifier',
- name: 'require',
- } as Identifier],
- } as ArrowFunctionExpression;
-
- let newCode = generate(newTab);
- if (newCode.endsWith(';')) newCode = newCode.slice(0, -1);
-
- const outFile = `${outDir}/tabs/${tabName}.js`;
- await fs.writeFile(outFile, newCode);
- const { size } = await fs.stat(outFile);
- return {
- severity: 'success',
- fileSize: size,
- };
- } catch (error) {
- return {
- severity: 'error',
- error,
- };
- }
-};
-
-export const tabOptions: ESBuildOptions = {
- ...esbuildOptions,
- jsx: 'automatic',
- external: ['react', 'react-dom', 'react/jsx-runtime'],
-};
-
-export const buildTabs = async (tabs: string[], { srcDir, outDir }: BuildOptions) => {
- const nameExpander = tabNameExpander(srcDir);
- const { outputFiles } = await esbuild({
- ...tabOptions,
- entryPoints: tabs.map(nameExpander),
- outbase: outDir,
- outdir: outDir,
- });
- return outputFiles;
-};
-
-export const reduceTabOutputFiles = (outputFiles: OutputFile[], startTime: number, outDir: string) => Promise.all(outputFiles.map(async ({ path, text }) => {
- const [rawType, name] = path.split(pathlib.sep)
- .slice(-3, -1);
-
- if (rawType !== 'tabs') {
- throw new Error(`Expected only tabs, got ${rawType}`);
- }
-
- const result = await outputTab(name, text, outDir);
- return ['tab', name, {
- elapsed: performance.now() - startTime,
- ...result,
- }] as UnreducedResult;
-}));
-
-const getBuildTabsCommand = () => createBuildCommand('tabs', true)
- .argument('[tabs...]', 'Manually specify which tabs to build', null)
- .description('Build only tabs')
- .action(async (tabsOpt: string[] | null, { manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
- const tabs = await retrieveTabs(manifest, tabsOpt);
-
- await prebuild(opts, {
- tabs,
- bundles: [],
- });
-
- printList(`${chalk.magentaBright('Building the following tabs:')}\n`, tabs);
- const startTime = performance.now();
- const [reducedRes] = await Promise.all([
- buildTabs(tabs, opts)
- .then((results) => reduceTabOutputFiles(results, startTime, opts.outDir)),
- copyManifest({
- outDir: opts.outDir,
- manifest,
- }),
- ]);
- logResult(reducedRes, opts.verbose);
- exitOnError(reducedRes);
- });
-
-
-export default getBuildTabsCommand;
+import { parse } from 'acorn';
+import { generate } from 'astring';
+import chalk from 'chalk';
+import {
+ type BuildOptions as ESBuildOptions,
+ type OutputFile,
+ type Plugin as ESBuildPlugin,
+ build as esbuild,
+} from 'esbuild';
+import type {
+ ExportDefaultDeclaration,
+ Identifier,
+ Literal,
+ MemberExpression,
+ Program,
+ VariableDeclaration,
+} from 'estree';
+import fs from 'fs/promises';
+import pathlib from 'path';
+
+import { printList } from '../../scriptUtils.js';
+import {
+ copyManifest,
+ createBuildCommand,
+ exitOnError,
+ logResult,
+ retrieveTabs,
+ tabNameExpander,
+} from '../buildUtils.js';
+import { prebuild } from '../prebuild/index.js';
+import type { LintCommandInputs } from '../prebuild/lint.js';
+import type { BuildCommandInputs, BuildOptions, BuildResult, UnreducedResult } from '../types';
+
+import { esbuildOptions } from './moduleUtils.js';
+
+const outputTab = async (tabName: string, text: string, outDir: string): Promise> => {
+ try {
+ const parsed = parse(text, { ecmaVersion: 6 }) as unknown as Program;
+ const declStatement = parsed.body[1] as VariableDeclaration;
+
+ const newTab: ExportDefaultDeclaration = {
+ type: 'ExportDefaultDeclaration',
+ declaration: {
+ type: 'ArrowFunctionExpression',
+ expression: true,
+ body: {
+ type: 'MemberExpression',
+ object: declStatement.declarations[0].init,
+ property: {
+ type: 'Literal',
+ value: 'default',
+ } as Literal,
+ computed: true,
+ } as MemberExpression,
+ params: [{
+ type: 'Identifier',
+ name: 'require',
+ } as Identifier],
+ },
+ };
+
+ const outFile = `${outDir}/tabs/${tabName}.js`;
+ await fs.writeFile(outFile, generate(newTab));
+ const { size } = await fs.stat(outFile);
+ return {
+ severity: 'success',
+ fileSize: size,
+ };
+ } catch (error) {
+ return {
+ severity: 'error',
+ error,
+ };
+ }
+};
+
+const tabContextPlugin: ESBuildPlugin = {
+ name: 'Tab Context',
+ setup(build) {
+ build.onResolve({ filter: /^js-slang\/context/u }, () => ({
+ errors: [{
+ text: 'If you see this message, it means that your tab code is importing js-slang/context directly or indirectly. Do not do this',
+ }],
+ }));
+ },
+};
+
+export const getTabOptions = (tabs: string[], { srcDir, outDir }: Record<'srcDir' | 'outDir', string>): ESBuildOptions => {
+ const nameExpander = tabNameExpander(srcDir);
+ return {
+ ...esbuildOptions,
+ entryPoints: tabs.map(nameExpander),
+ external: [
+ ...esbuildOptions.external,
+ 'react',
+ 'react-ace',
+ 'react-dom',
+ 'react/jsx-runtime',
+ '@blueprintjs/*',
+ // 'phaser',
+ ],
+ jsx: 'automatic',
+ outbase: outDir,
+ outdir: outDir,
+ tsconfig: `${srcDir}/tsconfig.json`,
+ plugins: [tabContextPlugin],
+ };
+};
+
+export const buildTabs = async (tabs: string[], options: BuildOptions) => {
+ const { outputFiles } = await esbuild(getTabOptions(tabs, options));
+ return outputFiles;
+};
+
+export const reduceTabOutputFiles = (outputFiles: OutputFile[], startTime: number, outDir: string) => Promise.all(outputFiles.map(async ({ path, text }) => {
+ const [rawType, name] = path.split(pathlib.sep)
+ .slice(-3, -1);
+
+ if (rawType !== 'tabs') {
+ throw new Error(`Expected only tabs, got ${rawType}`);
+ }
+
+ const result = await outputTab(name, text, outDir);
+ return ['tab', name, {
+ elapsed: performance.now() - startTime,
+ ...result,
+ }] as UnreducedResult;
+}));
+
+const getBuildTabsCommand = () => createBuildCommand('tabs', true)
+ .argument('[tabs...]', 'Manually specify which tabs to build', null)
+ .description('Build only tabs')
+ .action(async (tabsOpt: string[] | null, { manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
+ const tabs = await retrieveTabs(manifest, tabsOpt);
+
+ await prebuild(opts, {
+ tabs,
+ bundles: [],
+ });
+
+ printList(`${chalk.magentaBright('Building the following tabs:')}\n`, tabs);
+ const startTime = performance.now();
+ const [reducedRes] = await Promise.all([
+ buildTabs(tabs, opts)
+ .then((results) => reduceTabOutputFiles(results, startTime, opts.outDir)),
+ copyManifest({
+ outDir: opts.outDir,
+ manifest,
+ }),
+ ]);
+ logResult(reducedRes, opts.verbose);
+ exitOnError(reducedRes);
+ });
+
+
+export default getBuildTabsCommand;
diff --git a/scripts/src/build/prebuild/__mocks__/eslint.ts b/scripts/src/build/prebuild/__mocks__/lint.ts
similarity index 95%
rename from scripts/src/build/prebuild/__mocks__/eslint.ts
rename to scripts/src/build/prebuild/__mocks__/lint.ts
index a816f3498..ccb676b3f 100644
--- a/scripts/src/build/prebuild/__mocks__/eslint.ts
+++ b/scripts/src/build/prebuild/__mocks__/lint.ts
@@ -1,10 +1,10 @@
-export const runEslint = jest.fn().mockImplementation(() => ({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'error',
- }
-}))
-
+export const runEslint = jest.fn().mockImplementation(() => ({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'error',
+ }
+}))
+
export const logLintResult = jest.fn();
\ No newline at end of file
diff --git a/scripts/src/build/prebuild/__mocks__/tsc.ts b/scripts/src/build/prebuild/__mocks__/tsc.ts
index 56eb703a4..4e17827bf 100644
--- a/scripts/src/build/prebuild/__mocks__/tsc.ts
+++ b/scripts/src/build/prebuild/__mocks__/tsc.ts
@@ -1,8 +1,8 @@
-export const logTscResults = jest.fn();
-export const runTsc = jest.fn().mockResolvedValue({
- elapsed: 0,
- result: {
- severity: 'error',
- results: [],
- }
+export const logTscResults = jest.fn();
+export const runTsc = jest.fn().mockResolvedValue({
+ elapsed: 0,
+ result: {
+ severity: 'error',
+ results: [],
+ }
})
\ No newline at end of file
diff --git a/scripts/src/build/prebuild/__tests__/prebuild.test.ts b/scripts/src/build/prebuild/__tests__/prebuild.test.ts
index 3a49b939c..38a4a413f 100644
--- a/scripts/src/build/prebuild/__tests__/prebuild.test.ts
+++ b/scripts/src/build/prebuild/__tests__/prebuild.test.ts
@@ -1,313 +1,313 @@
-import type { MockedFunction } from 'jest-mock';
-
-import getLintCommand, * as lintModule from '../eslint';
-import getTscCommand, * as tscModule from '../tsc';
-import getPrebuildCommand from '..';
-
-jest.spyOn(lintModule, 'runEslint')
-jest.spyOn(tscModule, 'runTsc');
-
-const asMock = any>(func: T) => func as MockedFunction;
-const mockedTsc = asMock(tscModule.runTsc);
-const mockedEslint = asMock(lintModule.runEslint);
-
-describe('test eslint command', () => {
- const runCommand = (...args: string[]) => getLintCommand().parseAsync(args, { from: 'user' });
-
- test('regular command function', async () => {
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand();
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should only lint specified bundles and tabs', async () => {
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand('-m', 'test0', '-t', 'tab0');
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- const lintCall = mockedEslint.mock.calls[0];
- expect(lintCall[1])
- .toMatchObject({
- bundles: ['test0'],
- tabs: ['tab0']
- });
- });
-
- it('should exit with code 1 if there are linting errors', async () => {
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'error',
- }
- });
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-});
-
-describe('test tsc command', () => {
- const runCommand = (...args: string[]) => getTscCommand().parseAsync(args, { from: 'user' });
-
- test('regular command function', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand();
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should only typecheck specified bundles and tabs', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand('-m', 'test0', '-t', 'tab0');
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- const tscCall = mockedTsc.mock.calls[0];
- expect(tscCall[1])
- .toMatchObject({
- bundles: ['test0'],
- tabs: ['tab0']
- });
- });
-
- it('should exit with code 1 if there are type check errors', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'error',
- }
- });
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-});
-
-describe('test prebuild command', () => {
- const runCommand = (...args: string[]) => getPrebuildCommand().parseAsync(args, { from: 'user' });
-
- test('regular command function', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'success',
- }
- });
-
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand();
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
- });
-
- it('should only run on specified bundles and tabs', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'success',
- }
- });
-
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'success',
- }
- });
-
- await runCommand('-m', 'test0', '-t', 'tab0');
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- const tscCall = mockedTsc.mock.calls[0];
- expect(tscCall[1])
- .toMatchObject({
- bundles: ['test0'],
- tabs: ['tab0']
- });
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- const lintCall = mockedEslint.mock.calls[0];
- expect(lintCall[1])
- .toMatchObject({
- bundles: ['test0'],
- tabs: ['tab0']
- });
- });
-
- describe('test error functionality', () => {
- it('should exit with code 1 if there are type check errors', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'error',
- }
- });
-
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'success',
- }
- });
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it('should exit with code 1 if there are lint errors', async () => {
- mockedTsc.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- results: [],
- severity: 'success',
- }
- });
-
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'error',
- }
- });
-
- try {
- await runCommand();
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(1);
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
-
- it('should exit with code 1 and not run tsc if there are unfixable linting errors and --fix was specified', async () => {
- mockedEslint.mockResolvedValueOnce({
- elapsed: 0,
- result: {
- formatted: '',
- results: [],
- severity: 'error',
- }
- });
-
- try {
- await runCommand('--fix');
- } catch (error) {
- expect(error)
- .toEqual(new Error('process.exit called with 1'))
- }
-
- expect(tscModule.runTsc)
- .toHaveBeenCalledTimes(0);
-
- expect(lintModule.runEslint)
- .toHaveBeenCalledTimes(1);
-
- expect(process.exit)
- .toHaveBeenCalledWith(1);
- });
- });
-});
+import type { MockedFunction } from 'jest-mock';
+
+import getLintCommand, * as lintModule from '../lint';
+import getTscCommand, * as tscModule from '../tsc';
+import getPrebuildCommand from '..';
+
+jest.spyOn(lintModule, 'runEslint')
+jest.spyOn(tscModule, 'runTsc');
+
+const asMock = any>(func: T) => func as MockedFunction;
+const mockedTsc = asMock(tscModule.runTsc);
+const mockedEslint = asMock(lintModule.runEslint);
+
+describe('test eslint command', () => {
+ const runCommand = (...args: string[]) => getLintCommand().parseAsync(args, { from: 'user' });
+
+ test('regular command function', async () => {
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand();
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should only lint specified bundles and tabs', async () => {
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand('-m', 'test0', '-t', 'tab0');
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ const lintCall = mockedEslint.mock.calls[0];
+ expect(lintCall[1])
+ .toMatchObject({
+ bundles: ['test0'],
+ tabs: ['tab0']
+ });
+ });
+
+ it('should exit with code 1 if there are linting errors', async () => {
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'error',
+ }
+ });
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+});
+
+describe('test tsc command', () => {
+ const runCommand = (...args: string[]) => getTscCommand().parseAsync(args, { from: 'user' });
+
+ test('regular command function', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand();
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should only typecheck specified bundles and tabs', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand('-m', 'test0', '-t', 'tab0');
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ const tscCall = mockedTsc.mock.calls[0];
+ expect(tscCall[1])
+ .toMatchObject({
+ bundles: ['test0'],
+ tabs: ['tab0']
+ });
+ });
+
+ it('should exit with code 1 if there are type check errors', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'error',
+ }
+ });
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+});
+
+describe('test prebuild command', () => {
+ const runCommand = (...args: string[]) => getPrebuildCommand().parseAsync(args, { from: 'user' });
+
+ test('regular command function', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand();
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+ });
+
+ it('should only run on specified bundles and tabs', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ await runCommand('-m', 'test0', '-t', 'tab0');
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ const tscCall = mockedTsc.mock.calls[0];
+ expect(tscCall[1])
+ .toMatchObject({
+ bundles: ['test0'],
+ tabs: ['tab0']
+ });
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ const lintCall = mockedEslint.mock.calls[0];
+ expect(lintCall[1])
+ .toMatchObject({
+ bundles: ['test0'],
+ tabs: ['tab0']
+ });
+ });
+
+ describe('test error functionality', () => {
+ it('should exit with code 1 if there are type check errors', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'error',
+ }
+ });
+
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it('should exit with code 1 if there are lint errors', async () => {
+ mockedTsc.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ results: [],
+ severity: 'success',
+ }
+ });
+
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'error',
+ }
+ });
+
+ try {
+ await runCommand();
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(1);
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+
+ it('should exit with code 1 and not run tsc if there are unfixable linting errors and --fix was specified', async () => {
+ mockedEslint.mockResolvedValueOnce({
+ elapsed: 0,
+ result: {
+ formatted: '',
+ results: [],
+ severity: 'error',
+ }
+ });
+
+ try {
+ await runCommand('--fix');
+ } catch (error) {
+ expect(error)
+ .toEqual(new Error('process.exit called with 1'))
+ }
+
+ expect(tscModule.runTsc)
+ .toHaveBeenCalledTimes(0);
+
+ expect(lintModule.runEslint)
+ .toHaveBeenCalledTimes(1);
+
+ expect(process.exit)
+ .toHaveBeenCalledWith(1);
+ });
+ });
+});
diff --git a/scripts/src/build/prebuild/index.ts b/scripts/src/build/prebuild/index.ts
index d2361d22d..c76193b1b 100644
--- a/scripts/src/build/prebuild/index.ts
+++ b/scripts/src/build/prebuild/index.ts
@@ -1,90 +1,90 @@
-import { Command } from 'commander';
-
-import { exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
-import type { AssetInfo } from '../types.js';
-
-import { type LintCommandInputs, type LintOpts, logLintResult, runEslint } from './eslint.js';
-import { type TscCommandInputs, type TscOpts, logTscResults, runTsc } from './tsc.js';
-
-type PreBuildOpts = TscOpts & LintOpts & {
- lint: boolean;
- tsc: boolean;
-};
-
-type PreBuildResult = {
- lintResult: Awaited> | null;
- tscResult: Awaited> | null;
-};
-/**
- * Run both `tsc` and `eslint` in parallel if `--fix` was not specified. Otherwise, run eslint
- * to fix linting errors first, then run tsc for type checking
- *
- * @returns An object that contains the results from linting and typechecking
- */
-const prebuildInternal = async (opts: PreBuildOpts, assets: AssetInfo): Promise => {
- if (opts.fix) {
- // Run tsc and then lint
- const lintResult = await runEslint(opts, assets);
-
- if (!opts.tsc || lintResult.result.severity === 'error') {
- return {
- lintResult,
- tscResult: null,
- };
- }
-
- const tscResult = await runTsc(opts.srcDir, assets);
- return {
- lintResult,
- tscResult,
- };
- // eslint-disable-next-line no-else-return
- } else {
- const [lintResult, tscResult] = await Promise.all([
- opts.lint ? runEslint(opts, assets) : Promise.resolve(null),
- opts.tsc ? runTsc(opts.srcDir, assets) : Promise.resolve(null),
- ]);
-
- return {
- lintResult,
- tscResult,
- };
- }
-};
-
-/**
- * Run eslint and tsc based on the provided options, and exit with code 1
- * if either returns with an error status
- */
-export const prebuild = async (opts: PreBuildOpts, assets: AssetInfo) => {
- const { lintResult, tscResult } = await prebuildInternal(opts, assets);
- logLintResult(lintResult);
- logTscResults(tscResult);
-
- exitOnError([], lintResult?.result, tscResult?.result);
- if (lintResult?.result.severity === 'error' || tscResult?.result.severity === 'error') {
- throw new Error('Exiting for jest');
- }
-};
-
-type PrebuildCommandInputs = LintCommandInputs & TscCommandInputs;
-
-const getPrebuildCommand = () => new Command('prebuild')
- .description('Run both tsc and eslint')
- .option('--fix', 'Ask eslint to autofix linting errors', false)
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
- .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
- .action(async ({ modules, tabs, manifest, ...opts }: PrebuildCommandInputs) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs, false);
- await prebuild({
- ...opts,
- tsc: true,
- lint: true,
- }, assets);
- });
-
-export default getPrebuildCommand;
-export { default as getLintCommand } from './eslint.js';
-export { default as getTscCommand } from './tsc.js';
+import { Command } from 'commander';
+
+import { exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
+import type { AssetInfo } from '../types.js';
+
+import { type LintCommandInputs, type LintOpts, logLintResult, runEslint } from './lint.js';
+import { type TscCommandInputs, type TscOpts, logTscResults, runTsc } from './tsc.js';
+
+type PreBuildOpts = TscOpts & LintOpts & {
+ lint: boolean;
+ tsc: boolean;
+};
+
+type PreBuildResult = {
+ lintResult: Awaited> | null;
+ tscResult: Awaited> | null;
+};
+/**
+ * Run both `tsc` and `eslint` in parallel if `--fix` was not specified. Otherwise, run eslint
+ * to fix linting errors first, then run tsc for type checking
+ *
+ * @returns An object that contains the results from linting and typechecking
+ */
+const prebuildInternal = async (opts: PreBuildOpts, assets: AssetInfo): Promise => {
+ if (opts.fix) {
+ // Run tsc and then lint
+ const lintResult = await runEslint(opts, assets);
+
+ if (!opts.tsc || lintResult.result.severity === 'error') {
+ return {
+ lintResult,
+ tscResult: null,
+ };
+ }
+
+ const tscResult = await runTsc(opts.srcDir, assets);
+ return {
+ lintResult,
+ tscResult,
+ };
+ // eslint-disable-next-line no-else-return
+ } else {
+ const [lintResult, tscResult] = await Promise.all([
+ opts.lint ? runEslint(opts, assets) : Promise.resolve(null),
+ opts.tsc ? runTsc(opts.srcDir, assets) : Promise.resolve(null),
+ ]);
+
+ return {
+ lintResult,
+ tscResult,
+ };
+ }
+};
+
+/**
+ * Run eslint and tsc based on the provided options, and exit with code 1
+ * if either returns with an error status
+ */
+export const prebuild = async (opts: PreBuildOpts, assets: AssetInfo) => {
+ const { lintResult, tscResult } = await prebuildInternal(opts, assets);
+ logLintResult(lintResult);
+ logTscResults(tscResult);
+
+ exitOnError([], lintResult?.result, tscResult?.result);
+ if (lintResult?.result.severity === 'error' || tscResult?.result.severity === 'error') {
+ throw new Error('Exiting for jest');
+ }
+};
+
+type PrebuildCommandInputs = LintCommandInputs & TscCommandInputs;
+
+const getPrebuildCommand = () => new Command('prebuild')
+ .description('Run both tsc and eslint')
+ .option('--fix', 'Ask eslint to autofix linting errors', false)
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
+ .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
+ .action(async ({ modules, tabs, manifest, ...opts }: PrebuildCommandInputs) => {
+ const assets = await retrieveBundlesAndTabs(manifest, modules, tabs, false);
+ await prebuild({
+ ...opts,
+ tsc: true,
+ lint: true,
+ }, assets);
+ });
+
+export default getPrebuildCommand;
+export { default as getLintCommand } from './lint.js';
+export { default as getTscCommand } from './tsc.js';
diff --git a/scripts/src/build/prebuild/eslint.ts b/scripts/src/build/prebuild/lint.ts
similarity index 71%
rename from scripts/src/build/prebuild/eslint.ts
rename to scripts/src/build/prebuild/lint.ts
index 885499187..ca02c9902 100644
--- a/scripts/src/build/prebuild/eslint.ts
+++ b/scripts/src/build/prebuild/lint.ts
@@ -1,106 +1,115 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { ESLint } from 'eslint';
-import pathlib from 'path';
-
-import { findSeverity, printList, wrapWithTimer } from '../../scriptUtils.js';
-import { divideAndRound, exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
-import type { AssetInfo, BuildCommandInputs, Severity } from '../types.js';
-
-export type LintCommandInputs = ({
- lint: false;
- fix: false;
-} | {
- lint: true;
- fix: boolean;
-}) & {
- srcDir: string;
-};
-
-export type LintOpts = Omit;
-
-type LintResults = {
- formatted: string;
- results: ESLint.LintResult[],
- severity: Severity;
-};
-
-/**
- * Run eslint programmatically
- * Refer to https://eslint.org/docs/latest/integrate/nodejs-api for documentation
- */
-export const runEslint = wrapWithTimer(async (opts: LintOpts, { bundles, tabs }: AssetInfo): Promise => {
- const linter = new ESLint({
- cwd: pathlib.resolve(opts.srcDir),
- overrideConfigFile: '.eslintrc.cjs',
- extensions: ['ts', 'tsx'],
- fix: opts.fix,
- useEslintrc: false,
- });
-
- const promises: Promise[] = [
- bundles.length > 0 ? linter.lintFiles(bundles.map((bundle) => pathlib.join('bundles', bundle))) : Promise.resolve([]),
- tabs.length > 0 ? linter.lintFiles(tabs.map((tabName) => pathlib.join('tabs', tabName))) : Promise.resolve([]),
- ];
-
- if (bundles.length > 0) {
- printList(`${chalk.magentaBright('Running eslint on the following bundles')}:\n`, bundles);
- }
-
- if (tabs.length > 0) {
- printList(`${chalk.magentaBright('Running eslint on the following tabs')}:\n`, tabs);
- }
-
- const [lintBundles, lintTabs] = await Promise.all(promises);
- const lintResults = [...lintBundles, ...lintTabs];
-
- if (opts.fix) {
- console.log(chalk.magentaBright('Running eslint autofix...'));
- await ESLint.outputFixes(lintResults);
- }
-
- const lintSeverity = findSeverity(lintResults, ({ errorCount, warningCount }) => {
- if (errorCount > 0) return 'error';
- if (warningCount > 0) return 'warn';
- return 'success';
- });
-
- const outputFormatter = await linter.loadFormatter('stylish');
- const formatterOutput = outputFormatter.format(lintResults);
-
- return {
- formatted: typeof formatterOutput === 'string' ? formatterOutput : await formatterOutput,
- results: lintResults,
- severity: lintSeverity,
- };
-});
-
-export const logLintResult = (input: Awaited> | null) => {
- if (!input) return;
-
- const { elapsed, result: { formatted, severity } } = input;
- let errStr: string;
-
- if (severity === 'error') errStr = chalk.cyanBright('with ') + chalk.redBright('errors');
- else if (severity === 'warn') errStr = chalk.cyanBright('with ') + chalk.yellowBright('warnings');
- else errStr = chalk.greenBright('successfully');
-
- console.log(`${chalk.cyanBright(`Linting completed in ${divideAndRound(elapsed, 1000)}s ${errStr}:`)}\n${formatted}`);
-};
-
-const getLintCommand = () => new Command('lint')
- .description('Run eslint')
- .option('--fix', 'Ask eslint to autofix linting errors', false)
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules ', 'Manually specify which modules to check', null)
- .option('-t, --tabs ', 'Manually specify which tabs to check', null)
- .option('-v, --verbose', 'Display more information about the build results', false)
- .action(async ({ modules, tabs, manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs);
- const result = await runEslint(opts, assets);
- logLintResult(result);
- exitOnError([], result.result);
- });
-
-export default getLintCommand;
+import chalk from 'chalk';
+import { Command } from 'commander';
+import { ESLint } from 'eslint';
+import pathlib from 'path';
+
+import { findSeverity, printList, wrapWithTimer } from '../../scriptUtils.js';
+import { divideAndRound, exitOnError, retrieveBundlesAndTabs } from '../buildUtils.js';
+import type { AssetInfo, BuildCommandInputs, Severity } from '../types.js';
+
+export type LintCommandInputs = ({
+ lint: false;
+ fix: false;
+} | {
+ lint: true;
+ fix: boolean;
+}) & {
+ srcDir: string;
+};
+
+export type LintOpts = Omit;
+
+type LintResults = {
+ formatted: string;
+ results: ESLint.LintResult[],
+ severity: Severity;
+};
+
+/**
+ * Run eslint programmatically
+ * Refer to https://eslint.org/docs/latest/integrate/nodejs-api for documentation
+ */
+export const runEslint = wrapWithTimer(async (opts: LintOpts, { bundles, tabs }: Partial): Promise => {
+ const linter = new ESLint({
+ cwd: pathlib.resolve(opts.srcDir),
+ extensions: ['ts', 'tsx'],
+ fix: opts.fix,
+ });
+
+ const promises: Promise[] = [];
+ if (bundles?.length > 0 || tabs?.length > 0) {
+ // Lint specific bundles and tabs
+ if (bundles.length > 0) {
+ printList(`${chalk.magentaBright('Running eslint on the following bundles')}:\n`, bundles);
+ bundles.forEach((bundle) => promises.push(linter.lintFiles(pathlib.join('bundles', bundle))));
+ }
+
+ if (tabs.length > 0) {
+ printList(`${chalk.magentaBright('Running eslint on the following tabs')}:\n`, tabs);
+ tabs.forEach((tabName) => promises.push(linter.lintFiles(pathlib.join('tabs', tabName))));
+ }
+ } else {
+ // Glob all source files, then lint files based on eslint configuration
+ promises.push(linter.lintFiles('**/*.ts'));
+ console.log(`${chalk.magentaBright('Linting all files in')} ${opts.srcDir}`);
+ }
+
+ // const [lintBundles, lintTabs, lintMisc] = await Promise.all(promises);
+ const lintResults = (await Promise.all(promises)).flat();
+
+ if (opts.fix) {
+ console.log(chalk.magentaBright('Running eslint autofix...'));
+ await ESLint.outputFixes(lintResults);
+ }
+
+ const lintSeverity = findSeverity(lintResults, ({ errorCount, warningCount }) => {
+ if (errorCount > 0) return 'error';
+ if (warningCount > 0) return 'warn';
+ return 'success';
+ });
+
+ const outputFormatter = await linter.loadFormatter('stylish');
+ const formatterOutput = outputFormatter.format(lintResults);
+
+ return {
+ formatted: typeof formatterOutput === 'string' ? formatterOutput : await formatterOutput,
+ results: lintResults,
+ severity: lintSeverity,
+ };
+});
+
+export const logLintResult = (input: Awaited> | null) => {
+ if (!input) return;
+
+ const { elapsed, result: { formatted, severity } } = input;
+ let errStr: string;
+
+ if (severity === 'error') errStr = chalk.cyanBright('with ') + chalk.redBright('errors');
+ else if (severity === 'warn') errStr = chalk.cyanBright('with ') + chalk.yellowBright('warnings');
+ else errStr = chalk.greenBright('successfully');
+
+ console.log(`${chalk.cyanBright(`Linting completed in ${divideAndRound(elapsed, 1000)}s ${errStr}:`)}\n${formatted}`);
+};
+
+const getLintCommand = () => new Command('lint')
+ .description('Run eslint')
+ .option('--fix', 'Ask eslint to autofix linting errors', false)
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .option('-m, --modules ', 'Manually specify which modules to check', null)
+ .option('-t, --tabs ', 'Manually specify which tabs to check', null)
+ .option('-v, --verbose', 'Display more information about the build results', false)
+ .action(async ({ modules, tabs, manifest, ...opts }: BuildCommandInputs & LintCommandInputs) => {
+ const assets = modules !== null || tabs !== null
+ ? await retrieveBundlesAndTabs(manifest, modules, tabs)
+ : {
+ modules: undefined,
+ tabs: undefined,
+ };
+
+ const result = await runEslint(opts, assets);
+ logLintResult(result);
+ exitOnError([], result.result);
+ });
+
+export default getLintCommand;
diff --git a/scripts/src/build/prebuild/tsc.ts b/scripts/src/build/prebuild/tsc.ts
index 8536447a1..8153bda15 100644
--- a/scripts/src/build/prebuild/tsc.ts
+++ b/scripts/src/build/prebuild/tsc.ts
@@ -1,139 +1,139 @@
-import chalk from 'chalk';
-import { Command } from 'commander';
-import { existsSync, promises as fs } from 'fs';
-import pathlib from 'path';
-import ts, { type CompilerOptions } from 'typescript';
-
-import { printList, wrapWithTimer } from '../../scriptUtils.js';
-import { bundleNameExpander, divideAndRound, exitOnError, retrieveBundlesAndTabs, tabNameExpander } from '../buildUtils.js';
-import type { AssetInfo, CommandInputs, Severity } from '../types.js';
-
-type TscResult = {
- severity: Severity,
- results: ts.Diagnostic[];
- error?: any;
-};
-
-export type TscOpts = {
- srcDir: string;
-};
-
-type TsconfigResult = {
- severity: 'error';
- error?: any;
- results: ts.Diagnostic[];
-} | {
- severity: 'success';
- results: CompilerOptions;
-};
-
-const getTsconfig = async (srcDir: string): Promise => {
- // Step 1: Read the text from tsconfig.json
- const tsconfigLocation = pathlib.join(srcDir, 'tsconfig.json');
- if (!existsSync(tsconfigLocation)) {
- return {
- severity: 'error',
- results: [],
- error: `Could not locate tsconfig.json at ${tsconfigLocation}`,
- };
- }
- const configText = await fs.readFile(tsconfigLocation, 'utf-8');
-
- // Step 2: Parse the raw text into a json object
- const { error: configJsonError, config: configJson } = ts.parseConfigFileTextToJson(tsconfigLocation, configText);
- if (configJsonError) {
- return {
- severity: 'error',
- results: [configJsonError],
- };
- }
-
- // Step 3: Parse the json object into a config object for use by tsc
- const { errors: parseErrors, options: tsconfig } = ts.parseJsonConfigFileContent(configJson, ts.sys, srcDir);
- if (parseErrors.length > 0) {
- return {
- severity: 'error',
- results: parseErrors,
- };
- }
-
- return {
- severity: 'success',
- results: tsconfig,
- };
-};
-
-/**
- * @param params_0 Source Directory
- */
-export const runTsc = wrapWithTimer((async (srcDir: string, { bundles, tabs }: AssetInfo): Promise => {
- const fileNames: string[] = [];
-
- if (bundles.length > 0) {
- printList(`${chalk.magentaBright('Running tsc on the following bundles')}:\n`, bundles);
- bundles.forEach((bundle) => fileNames.push(bundleNameExpander(srcDir)(bundle)));
- }
-
- if (tabs.length > 0) {
- printList(`${chalk.magentaBright('Running tsc on the following tabs')}:\n`, tabs);
- tabs.forEach((tabName) => fileNames.push(tabNameExpander(srcDir)(tabName)));
- }
-
- const tsconfigRes = await getTsconfig(srcDir);
- if (tsconfigRes.severity === 'error') {
- return {
- severity: 'error',
- results: tsconfigRes.results,
- };
- }
-
- const tsc = ts.createProgram(fileNames, tsconfigRes.results);
- const results = tsc.emit();
- const diagnostics = ts.getPreEmitDiagnostics(tsc)
- .concat(results.diagnostics);
-
- return {
- severity: diagnostics.length > 0 ? 'error' : 'success',
- results: diagnostics,
- };
-}));
-
-export const logTscResults = (input: Awaited> | null) => {
- if (!input) return;
-
- const { elapsed, result: { severity, results, error } } = input;
- if (error) {
- console.log(`${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')}:`)} ${error}`);
- return;
- }
-
- const diagStr = ts.formatDiagnosticsWithColorAndContext(results, {
- getNewLine: () => '\n',
- getCurrentDirectory: () => pathlib.resolve('.'),
- getCanonicalFileName: (name) => pathlib.basename(name),
- });
-
- if (severity === 'error') {
- console.log(`${diagStr}\n${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')} in ${divideAndRound(elapsed, 1000)}s`)}`);
- } else {
- console.log(`${diagStr}\n${chalk.cyanBright(`tsc completed ${chalk.greenBright('successfully')} in ${divideAndRound(elapsed, 1000)}s`)}`);
- }
-};
-
-export type TscCommandInputs = CommandInputs;
-
-const getTscCommand = () => new Command('typecheck')
- .description('Run tsc to perform type checking')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
- .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
- .option('-v, --verbose', 'Display more information about the build results', false)
- .action(async ({ modules, tabs, manifest, srcDir }: TscCommandInputs) => {
- const assets = await retrieveBundlesAndTabs(manifest, modules, tabs);
- const tscResults = await runTsc(srcDir, assets);
- logTscResults(tscResults);
- exitOnError([], tscResults.result);
- });
-
-export default getTscCommand;
+import chalk from 'chalk';
+import { Command } from 'commander';
+import { existsSync, promises as fs } from 'fs';
+import pathlib from 'path';
+import ts, { type CompilerOptions } from 'typescript';
+
+import { printList, wrapWithTimer } from '../../scriptUtils.js';
+import { bundleNameExpander, divideAndRound, exitOnError, retrieveBundlesAndTabs, tabNameExpander } from '../buildUtils.js';
+import type { AssetInfo, CommandInputs, Severity } from '../types.js';
+
+type TscResult = {
+ severity: Severity,
+ results: ts.Diagnostic[];
+ error?: any;
+};
+
+export type TscOpts = {
+ srcDir: string;
+};
+
+type TsconfigResult = {
+ severity: 'error';
+ error?: any;
+ results: ts.Diagnostic[];
+} | {
+ severity: 'success';
+ results: CompilerOptions;
+};
+
+const getTsconfig = async (srcDir: string): Promise => {
+ // Step 1: Read the text from tsconfig.json
+ const tsconfigLocation = pathlib.join(srcDir, 'tsconfig.json');
+ if (!existsSync(tsconfigLocation)) {
+ return {
+ severity: 'error',
+ results: [],
+ error: `Could not locate tsconfig.json at ${tsconfigLocation}`,
+ };
+ }
+ const configText = await fs.readFile(tsconfigLocation, 'utf-8');
+
+ // Step 2: Parse the raw text into a json object
+ const { error: configJsonError, config: configJson } = ts.parseConfigFileTextToJson(tsconfigLocation, configText);
+ if (configJsonError) {
+ return {
+ severity: 'error',
+ results: [configJsonError],
+ };
+ }
+
+ // Step 3: Parse the json object into a config object for use by tsc
+ const { errors: parseErrors, options: tsconfig } = ts.parseJsonConfigFileContent(configJson, ts.sys, srcDir);
+ if (parseErrors.length > 0) {
+ return {
+ severity: 'error',
+ results: parseErrors,
+ };
+ }
+
+ return {
+ severity: 'success',
+ results: tsconfig,
+ };
+};
+
+/**
+ * @param params_0 Source Directory
+ */
+export const runTsc = wrapWithTimer((async (srcDir: string, { bundles, tabs }: AssetInfo): Promise => {
+ const fileNames: string[] = [];
+
+ if (bundles.length > 0) {
+ printList(`${chalk.magentaBright('Running tsc on the following bundles')}:\n`, bundles);
+ bundles.forEach((bundle) => fileNames.push(bundleNameExpander(srcDir)(bundle)));
+ }
+
+ if (tabs.length > 0) {
+ printList(`${chalk.magentaBright('Running tsc on the following tabs')}:\n`, tabs);
+ tabs.forEach((tabName) => fileNames.push(tabNameExpander(srcDir)(tabName)));
+ }
+
+ const tsconfigRes = await getTsconfig(srcDir);
+ if (tsconfigRes.severity === 'error') {
+ return {
+ severity: 'error',
+ results: tsconfigRes.results,
+ };
+ }
+
+ const tsc = ts.createProgram(fileNames, tsconfigRes.results);
+ const results = tsc.emit();
+ const diagnostics = ts.getPreEmitDiagnostics(tsc)
+ .concat(results.diagnostics);
+
+ return {
+ severity: diagnostics.length > 0 ? 'error' : 'success',
+ results: diagnostics,
+ };
+}));
+
+export const logTscResults = (input: Awaited> | null) => {
+ if (!input) return;
+
+ const { elapsed, result: { severity, results, error } } = input;
+ if (error) {
+ console.log(`${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')}:`)} ${error}`);
+ return;
+ }
+
+ const diagStr = ts.formatDiagnosticsWithColorAndContext(results, {
+ getNewLine: () => '\n',
+ getCurrentDirectory: () => pathlib.resolve('.'),
+ getCanonicalFileName: (name) => pathlib.basename(name),
+ });
+
+ if (severity === 'error') {
+ console.log(`${diagStr}\n${chalk.cyanBright(`tsc finished with ${chalk.redBright('errors')} in ${divideAndRound(elapsed, 1000)}s`)}`);
+ } else {
+ console.log(`${diagStr}\n${chalk.cyanBright(`tsc completed ${chalk.greenBright('successfully')} in ${divideAndRound(elapsed, 1000)}s`)}`);
+ }
+};
+
+export type TscCommandInputs = CommandInputs;
+
+const getTscCommand = () => new Command('typecheck')
+ .description('Run tsc to perform type checking')
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .option('-m, --modules [modules...]', 'Manually specify which modules to check', null)
+ .option('-t, --tabs [tabs...]', 'Manually specify which tabs to check', null)
+ .option('-v, --verbose', 'Display more information about the build results', false)
+ .action(async ({ modules, tabs, manifest, srcDir }: TscCommandInputs) => {
+ const assets = await retrieveBundlesAndTabs(manifest, modules, tabs);
+ const tscResults = await runTsc(srcDir, assets);
+ logTscResults(tscResults);
+ exitOnError([], tscResults.result);
+ });
+
+export default getTscCommand;
diff --git a/scripts/src/build/types.ts b/scripts/src/build/types.ts
index d7549295c..0a994a38a 100644
--- a/scripts/src/build/types.ts
+++ b/scripts/src/build/types.ts
@@ -1,102 +1,102 @@
-export type Severity = 'success' | 'error' | 'warn';
-
-export const Assets = ['bundle', 'tab', 'json'] as const;
-
-/**
- * Type of assets that can be built
- */
-export type AssetTypes = typeof Assets[number];
-
-/**
- * Represents the result of a single operation (like building a single bundle)
- */
-export type OperationResult = {
- /**
- * Overall success of operation
- */
- severity: Severity;
-
- /**
- * Any warning or error messages
- */
- error?: any;
-};
-
-/**
- * Represents the result of an operation that results in a file written to disk
- */
-export type BuildResult = {
- /**
- * Time taken (im milliseconds) for the operation to complete
- */
- elapsed?: number;
-
- /**
- * Size (in bytes) of the file written to disk
- */
- fileSize?: number;
-} & OperationResult;
-
-/**
- * Represents the collective result of a number of operations (like `buildJsons`)
- */
-export type OverallResult = {
- severity: Severity;
- results: Record;
-} | null;
-
-/**
- * A different form of `buildResult` with the associated asset type and name.
- */
-export type UnreducedResult = [AssetTypes, string, BuildResult];
-
-/**
- * Options common to all commands
- */
-export type CommandInputs = {
- /**
- * Directory containing source files
- */
- srcDir: string;
-
- /**
- * Enable verbose logging
- */
- verbose: boolean;
-
- /**
- * Location of the manifest file
- */
- manifest: string;
-
- /**
- * String array containing the modules the user specified, or `null` if they specified none
- */
- modules: string[] | null;
-
- /**
- * String array containing the tabs the user specified, or `null` if they specified none
- */
- tabs: string[] | null;
-};
-
-/**
- * Options specific to commands that output files
- */
-export type BuildCommandInputs = {
- outDir: string;
- tsc: boolean;
-} & CommandInputs;
-
-/**
- * Options that are passed to command handlers
- */
-export type BuildOptions = Omit;
-
-/**
- * Specifies which bundles and tabs are to be built
- */
-export type AssetInfo = {
- bundles: string[];
- tabs: string[];
-};
+export type Severity = 'success' | 'error' | 'warn';
+
+export const Assets = ['bundle', 'tab', 'json'] as const;
+
+/**
+ * Type of assets that can be built
+ */
+export type AssetTypes = typeof Assets[number];
+
+/**
+ * Represents the result of a single operation (like building a single bundle)
+ */
+export type OperationResult = {
+ /**
+ * Overall success of operation
+ */
+ severity: Severity;
+
+ /**
+ * Any warning or error messages
+ */
+ error?: any;
+};
+
+/**
+ * Represents the result of an operation that results in a file written to disk
+ */
+export type BuildResult = {
+ /**
+ * Time taken (im milliseconds) for the operation to complete
+ */
+ elapsed?: number;
+
+ /**
+ * Size (in bytes) of the file written to disk
+ */
+ fileSize?: number;
+} & OperationResult;
+
+/**
+ * Represents the collective result of a number of operations (like `buildJsons`)
+ */
+export type OverallResult = {
+ severity: Severity;
+ results: Record;
+} | null;
+
+/**
+ * A different form of `buildResult` with the associated asset type and name.
+ */
+export type UnreducedResult = [AssetTypes, string, BuildResult];
+
+/**
+ * Options common to all commands
+ */
+export type CommandInputs = {
+ /**
+ * Directory containing source files
+ */
+ srcDir: string;
+
+ /**
+ * Enable verbose logging
+ */
+ verbose: boolean;
+
+ /**
+ * Location of the manifest file
+ */
+ manifest: string;
+
+ /**
+ * String array containing the modules the user specified, or `null` if they specified none
+ */
+ modules: string[] | null;
+
+ /**
+ * String array containing the tabs the user specified, or `null` if they specified none
+ */
+ tabs: string[] | null;
+};
+
+/**
+ * Options specific to commands that output files
+ */
+export type BuildCommandInputs = {
+ outDir: string;
+ tsc: boolean;
+} & CommandInputs;
+
+/**
+ * Options that are passed to command handlers
+ */
+export type BuildOptions = Omit;
+
+/**
+ * Specifies which bundles and tabs are to be built
+ */
+export type AssetInfo = {
+ bundles: string[];
+ tabs: string[];
+};
diff --git a/scripts/src/devserver/index.ts b/scripts/src/devserver/index.ts
new file mode 100644
index 000000000..9fd19e7cb
--- /dev/null
+++ b/scripts/src/devserver/index.ts
@@ -0,0 +1,36 @@
+import chalk from 'chalk';
+import { Command } from 'commander';
+import { ESLint } from 'eslint';
+import pathlib from 'path';
+
+// Separate command for running ESlint because running it straight
+// from yarn tends not to work properly
+const lintDevServerCommand = new Command('lint')
+ .option('--fix', 'Fix auto fixable issues', false)
+ .action(async ({ fix }: { fix: boolean }) => {
+ const srcDir = pathlib.resolve('./devserver/src');
+ const linter = new ESLint({
+ cwd: srcDir,
+ extensions: ['ts', 'tsx'],
+ });
+
+ const results = await linter.lintFiles('**/*.ts*');
+
+ if (fix) {
+ console.log(chalk.magentaBright('Running eslint autofix...'));
+ await ESLint.outputFixes(results);
+ }
+
+ const outputFormatter = await linter.loadFormatter('stylish');
+ const formatterOutput = outputFormatter.format(results);
+
+ console.log(formatterOutput);
+
+ const isError = results.find(({ errorCount }) => errorCount > 0);
+ if (isError) process.exit(1);
+ });
+
+const devserverCommand = new Command('devserver')
+ .addCommand(lintDevServerCommand);
+
+export default devserverCommand;
diff --git a/scripts/src/index.ts b/scripts/src/index.ts
index 834b8b3fd..fb0894deb 100644
--- a/scripts/src/index.ts
+++ b/scripts/src/index.ts
@@ -1,17 +1,21 @@
-import { Command } from 'commander';
-
-import { watchCommand } from './build/dev.js';
-import buildAllCommand from './build/index.js';
-import getPrebuildCommand, { getLintCommand, getTscCommand } from './build/prebuild/index.js';
-import createCommand from './templates/index.js';
-
-const parser = new Command()
- .addCommand(buildAllCommand)
- .addCommand(createCommand)
- .addCommand(getLintCommand())
- .addCommand(getPrebuildCommand())
- .addCommand(getTscCommand())
- .addCommand(watchCommand);
-
-await parser.parseAsync();
-process.exit();
+import { Command } from 'commander';
+
+import { watchCommand } from './build/dev.js';
+import buildAllCommand from './build/index.js';
+import getPrebuildCommand, { getLintCommand, getTscCommand } from './build/prebuild/index.js';
+import devserverCommand from './devserver/index.js';
+import createCommand from './templates/index.js';
+import getTestCommand from './testing/index.js';
+
+const parser = new Command()
+ .addCommand(buildAllCommand)
+ .addCommand(createCommand)
+ .addCommand(getLintCommand())
+ .addCommand(getPrebuildCommand())
+ .addCommand(getTscCommand())
+ .addCommand(getTestCommand())
+ .addCommand(watchCommand)
+ .addCommand(devserverCommand);
+
+await parser.parseAsync();
+process.exit();
diff --git a/scripts/src/jest.config.js b/scripts/src/jest.config.js
index 5a202eb98..b93ea973d 100644
--- a/scripts/src/jest.config.js
+++ b/scripts/src/jest.config.js
@@ -1,46 +1,49 @@
-import presets from 'ts-jest/presets/index.js';
-
-const preset = presets.jsWithTsESM;
-const [[transformKey, [, transforms]]] = Object.entries(preset.transform);
-
-/**
- * @type {import('jest').config}
- */
-export default {
- clearMocks: true,
- displayName: 'Scripts',
- testEnvironment: 'node',
- extensionsToTreatAsEsm: ['.ts'],
- rootDir: '../../',
- modulePaths: [
- '/scripts/src',
- ],
- moduleDirectories: [
- '/node_modules',
- '/scripts/src',
- ],
- transform: {
- [transformKey]: ['ts-jest', {
- ...transforms,
- // tsconfig: '/scripts/src/tsconfig.json',
- tsconfig: {
- allowSyntheticDefaultImports: true,
- allowJs: true,
- esModuleInterop: true,
- module: 'es2022',
- moduleResolution: 'node',
- resolveJsonModule: true,
- target: 'es2022',
- },
- }],
- },
- // Module Name settings required to make chalk work with jest
- moduleNameMapper: {
- 'chalk': '/scripts/src/__mocks__/chalk.cjs',
- '(.+)\\.js': '$1',
- },
- testMatch: [
- '/scripts/src/**/__tests__/**/*.test.ts',
- ],
- setupFilesAfterEnv: ["/scripts/src/jest.setup.ts"]
-};
+import presets from 'ts-jest/presets/index.js';
+
+/* For some reason, using the preset and giving the tsconfig as a config option
+ * doesn't work, hence the very complicated code here for configuring jest
+ */
+const preset = presets.jsWithTsESM;
+const [[transformKey, [, transforms]]] = Object.entries(preset.transform);
+
+/**
+ * @type {import('jest').config}
+ */
+export default {
+ clearMocks: true,
+ displayName: 'Scripts',
+ testEnvironment: 'node',
+ extensionsToTreatAsEsm: ['.ts'],
+ rootDir: '../../',
+ modulePaths: [
+ '/scripts/src',
+ ],
+ moduleDirectories: [
+ '/node_modules',
+ '/scripts/src',
+ ],
+ transform: {
+ [transformKey]: ['ts-jest', {
+ ...transforms,
+ // tsconfig: '/scripts/src/tsconfig.json',
+ tsconfig: {
+ allowSyntheticDefaultImports: true,
+ allowJs: true,
+ esModuleInterop: true,
+ module: 'es2022',
+ moduleResolution: 'node',
+ resolveJsonModule: true,
+ target: 'es2022',
+ },
+ }],
+ },
+ // Module Name settings required to make chalk work with jest
+ moduleNameMapper: {
+ 'chalk': '/scripts/src/__mocks__/chalk.cjs',
+ '(.+)\\.js': '$1',
+ },
+ testMatch: [
+ '/scripts/src/**/__tests__/**/*.test.ts',
+ ],
+ setupFilesAfterEnv: ["/scripts/src/jest.setup.ts"]
+};
diff --git a/scripts/src/jest.setup.ts b/scripts/src/jest.setup.ts
index fb816c2b0..fe86c3b30 100644
--- a/scripts/src/jest.setup.ts
+++ b/scripts/src/jest.setup.ts
@@ -1,25 +1,25 @@
-jest.mock('fs/promises', () => ({
- copyFile: jest.fn(() => Promise.resolve()),
- mkdir: jest.fn(() => Promise.resolve()),
- stat: jest.fn().mockResolvedValue({ size: 10 }),
- writeFile: jest.fn(() => Promise.resolve()),
-}));
-
-jest.mock('./scriptUtils', () => ({
- ...jest.requireActual('./scriptUtils'),
- retrieveManifest: jest.fn(() => Promise.resolve({
- test0: {
- tabs: ['tab0'],
- },
- test1: { tabs: [] },
- test2: {
- tabs: ['tab1'],
- },
- })),
-}));
-
-jest.mock('./build/docs/docUtils');
-
-jest.spyOn(process, 'exit').mockImplementation(code => {
- throw new Error(`process.exit called with ${code}`)
-});
+jest.mock('fs/promises', () => ({
+ copyFile: jest.fn(() => Promise.resolve()),
+ mkdir: jest.fn(() => Promise.resolve()),
+ stat: jest.fn().mockResolvedValue({ size: 10 }),
+ writeFile: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('./scriptUtils', () => ({
+ ...jest.requireActual('./scriptUtils'),
+ retrieveManifest: jest.fn(() => Promise.resolve({
+ test0: {
+ tabs: ['tab0'],
+ },
+ test1: { tabs: [] },
+ test2: {
+ tabs: ['tab1'],
+ },
+ })),
+}));
+
+jest.mock('./build/docs/docUtils');
+
+jest.spyOn(process, 'exit').mockImplementation(code => {
+ throw new Error(`process.exit called with ${code}`)
+});
diff --git a/scripts/src/scriptUtils.ts b/scripts/src/scriptUtils.ts
index e0edce3ea..e94aa9451 100644
--- a/scripts/src/scriptUtils.ts
+++ b/scripts/src/scriptUtils.ts
@@ -1,59 +1,79 @@
-import { readFile } from 'fs/promises';
-import { dirname, join } from 'path';
-import { fileURLToPath } from 'url';
-
-import type { Severity } from './build/types';
-
-export type ModuleManifest = Record;
-
-export function cjsDirname(url: string) {
- return join(dirname(fileURLToPath(url)));
-}
-
-export const retrieveManifest = async (manifest: string) => {
- try {
- const rawManifest = await readFile(manifest, 'utf-8');
- return JSON.parse(rawManifest) as ModuleManifest;
- } catch (error) {
- if (error.code === 'ENOENT') throw new Error(`Could not locate manifest file at ${manifest}`);
- throw error;
- }
-};
-
-export const wrapWithTimer = Promise>(func: T) => async (...params: Parameters): Promise<{
- elapsed: number,
- result: Awaited>
-}> => {
- const startTime = performance.now();
- const result = await func(...params);
- const endTime = performance.now();
-
- return {
- elapsed: endTime - startTime,
- result,
- };
-};
-
-export const printList = (header: string, lst: T[], mapper?: (each: T) => string, sep: string = '\n') => {
- const mappingFunction = mapper || ((each) => {
- if (typeof each === 'string') return each;
- return `${each}`;
- });
-
- console.log(`${header}\n${
- lst.map((str, i) => `${i + 1}. ${mappingFunction(str)}`)
- .join(sep)
- }`);
-};
-
-export const findSeverity = (items: T[], converter: (item: T) => Severity) => {
- let output: Severity = 'success';
- for (const item of items) {
- const severity = converter(item);
- if (severity === 'error') return 'error';
- if (severity === 'warn') output = 'warn';
- }
- return output;
-};
+import { readFile } from 'fs/promises';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+
+import type { Severity } from './build/types';
+
+export type ModuleManifest = Record;
+
+/**
+ * Function to replicate `__dirname` in CommonJS modules
+ * Use with `import.meta.url`
+ */
+export function cjsDirname(url: string) {
+ return join(dirname(fileURLToPath(url)));
+}
+
+export const retrieveManifest = async (manifest: string) => {
+ try {
+ const rawManifest = await readFile(manifest, 'utf-8');
+ return JSON.parse(rawManifest) as ModuleManifest;
+ } catch (error) {
+ if (error.code === 'ENOENT') throw new Error(`Could not locate manifest file at ${manifest}`);
+ throw error;
+ }
+};
+
+export const wrapWithTimer = Promise>(func: T) => async (...params: Parameters): Promise<{
+ elapsed: number,
+ result: Awaited>
+}> => {
+ const startTime = performance.now();
+ const result = await func(...params);
+ const endTime = performance.now();
+
+ return {
+ elapsed: endTime - startTime,
+ result,
+ };
+};
+
+export const printList = (header: string, lst: T[], mapper?: (each: T) => string, sep: string = '\n') => {
+ const mappingFunction = mapper || ((each) => {
+ if (typeof each === 'string') return each;
+ return `${each}`;
+ });
+
+ console.log(`${header}\n${
+ lst.map((str, i) => `${i + 1}. ${mappingFunction(str)}`)
+ .join(sep)
+ }`);
+};
+
+export const findSeverity = (items: T[], converter: (item: T) => Severity) => {
+ let output: Severity = 'success';
+ for (const item of items) {
+ const severity = converter(item);
+ if (severity === 'error') return 'error';
+ if (severity === 'warn') output = 'warn';
+ }
+ return output;
+};
+
+/**
+ * Wait until the user presses 'ctrl+c' on the keyboard
+ */
+export const waitForQuit = () => new Promise((resolve, reject) => {
+ process.stdin.setRawMode(true);
+ process.stdin.on('data', (data) => {
+ const byteArray = [...data];
+ if (byteArray.length > 0 && byteArray[0] === 3) {
+ console.log('^C');
+ process.stdin.setRawMode(false);
+ resolve();
+ }
+ });
+ process.stdin.on('error', reject);
+});
diff --git a/scripts/src/templates/index.ts b/scripts/src/templates/index.ts
index 2bbb6fa41..5041c8dfd 100644
--- a/scripts/src/templates/index.ts
+++ b/scripts/src/templates/index.ts
@@ -1,37 +1,37 @@
-import { Command } from 'commander';
-
-import { addNew as addNewModule } from './module.js';
-import { askQuestion, error as _error, info, rl, warn } from './print.js';
-import { addNew as addNewTab } from './tab.js';
-import type { Options } from './utilities.js';
-
-async function askMode() {
- while (true) {
- // eslint-disable-next-line no-await-in-loop
- const mode = await askQuestion(
- 'What would you like to create? (module/tab)',
- );
- if (mode !== 'module' && mode !== 'tab') {
- warn("Please answer with only 'module' or 'tab'.");
- } else {
- return mode;
- }
- }
-}
-
-export default new Command('create')
- .option('--srcDir ', 'Source directory for files', 'src')
- .option('--manifest ', 'Manifest file', 'modules.json')
- .description('Interactively create a new module or tab')
- .action(async (buildOpts: Options) => {
- try {
- const mode = await askMode();
- if (mode === 'module') await addNewModule(buildOpts);
- else if (mode === 'tab') await addNewTab(buildOpts);
- } catch (error) {
- _error(`ERROR: ${error.message}`);
- info('Terminating module app...');
- } finally {
- rl.close();
- }
- });
+import { Command } from 'commander';
+
+import { addNew as addNewModule } from './module.js';
+import { askQuestion, error as _error, info, rl, warn } from './print.js';
+import { addNew as addNewTab } from './tab.js';
+import type { Options } from './utilities.js';
+
+async function askMode() {
+ while (true) {
+ // eslint-disable-next-line no-await-in-loop
+ const mode = await askQuestion(
+ 'What would you like to create? (module/tab)',
+ );
+ if (mode !== 'module' && mode !== 'tab') {
+ warn("Please answer with only 'module' or 'tab'.");
+ } else {
+ return mode;
+ }
+ }
+}
+
+export default new Command('create')
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .option('--manifest ', 'Manifest file', 'modules.json')
+ .description('Interactively create a new module or tab')
+ .action(async (buildOpts: Options) => {
+ try {
+ const mode = await askMode();
+ if (mode === 'module') await addNewModule(buildOpts);
+ else if (mode === 'tab') await addNewTab(buildOpts);
+ } catch (error) {
+ _error(`ERROR: ${error.message}`);
+ info('Terminating module app...');
+ } finally {
+ rl.close();
+ }
+ });
diff --git a/scripts/src/templates/module.ts b/scripts/src/templates/module.ts
index 1527e58c7..300c9aaf6 100644
--- a/scripts/src/templates/module.ts
+++ b/scripts/src/templates/module.ts
@@ -1,45 +1,45 @@
-import { promises as fs } from 'fs';
-
-import { type ModuleManifest, cjsDirname, retrieveManifest } from '../scriptUtils.js';
-
-import { askQuestion, success, warn } from './print.js';
-import { type Options, isSnakeCase } from './utilities.js';
-
-export const check = (manifest: ModuleManifest, name: string) => Object.keys(manifest)
- .includes(name);
-
-async function askModuleName(manifest: ModuleManifest) {
- while (true) {
- // eslint-disable-next-line no-await-in-loop
- const name = await askQuestion(
- 'What is the name of your new module? (eg. binary_tree)',
- );
- if (isSnakeCase(name) === false) {
- warn('Module names must be in snake case. (eg. binary_tree)');
- } else if (check(manifest, name)) {
- warn('A module with the same name already exists.');
- } else {
- return name;
- }
- }
-}
-
-export async function addNew(buildOpts: Options) {
- const manifest = await retrieveManifest(buildOpts.manifest);
- const moduleName = await askModuleName(manifest);
-
- const bundleDestination = `${buildOpts.srcDir}/bundles/${moduleName}`;
- await fs.mkdir(bundleDestination, { recursive: true });
- await fs.copyFile(
- `${cjsDirname(import.meta.url)}/templates/__bundle__.ts`,
- `${bundleDestination}/index.ts`,
- );
- await fs.writeFile(
- 'modules.json',
- JSON.stringify({
- ...manifest,
- [moduleName]: { tabs: [] },
- }, null, 2),
- );
- success(`Bundle for module ${moduleName} created at ${bundleDestination}.`);
-}
+import { promises as fs } from 'fs';
+
+import { type ModuleManifest, retrieveManifest } from '../scriptUtils.js';
+
+import { askQuestion, success, warn } from './print.js';
+import { type Options, isSnakeCase } from './utilities.js';
+
+export const check = (manifest: ModuleManifest, name: string) => Object.keys(manifest)
+ .includes(name);
+
+async function askModuleName(manifest: ModuleManifest) {
+ while (true) {
+ // eslint-disable-next-line no-await-in-loop
+ const name = await askQuestion(
+ 'What is the name of your new module? (eg. binary_tree)',
+ );
+ if (isSnakeCase(name) === false) {
+ warn('Module names must be in snake case. (eg. binary_tree)');
+ } else if (check(manifest, name)) {
+ warn('A module with the same name already exists.');
+ } else {
+ return name;
+ }
+ }
+}
+
+export async function addNew(buildOpts: Options) {
+ const manifest = await retrieveManifest(buildOpts.manifest);
+ const moduleName = await askModuleName(manifest);
+
+ const bundleDestination = `${buildOpts.srcDir}/bundles/${moduleName}`;
+ await fs.mkdir(bundleDestination, { recursive: true });
+ await fs.copyFile(
+ './scripts/src/templates/templates/__bundle__.ts',
+ `${bundleDestination}/index.ts`,
+ );
+ await fs.writeFile(
+ 'modules.json',
+ JSON.stringify({
+ ...manifest,
+ [moduleName]: { tabs: [] },
+ }, null, 2),
+ );
+ success(`Bundle for module ${moduleName} created at ${bundleDestination}.`);
+}
diff --git a/scripts/src/templates/print.ts b/scripts/src/templates/print.ts
index 9433dd499..97dbf0abe 100644
--- a/scripts/src/templates/print.ts
+++ b/scripts/src/templates/print.ts
@@ -1,29 +1,29 @@
-import chalk from 'chalk';
-import { createInterface } from 'readline';
-
-export const rl = createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-export function info(...args) {
- return console.log(...args.map((string) => chalk.grey(string)));
-}
-
-export function error(...args) {
- return console.log(...args.map((string) => chalk.red(string)));
-}
-
-export function warn(...args) {
- return console.log(...args.map((string) => chalk.yellow(string)));
-}
-
-export function success(...args) {
- return console.log(...args.map((string) => chalk.green(string)));
-}
-
-export function askQuestion(question: string) {
- return new Promise((resolve) => {
- rl.question(chalk.blueBright(`${question}\n`), resolve);
- });
-}
+import chalk from 'chalk';
+import { createInterface } from 'readline';
+
+export const rl = createInterface({
+ input: process.stdin,
+ output: process.stdout,
+});
+
+export function info(...args) {
+ return console.log(...args.map((string) => chalk.grey(string)));
+}
+
+export function error(...args) {
+ return console.log(...args.map((string) => chalk.red(string)));
+}
+
+export function warn(...args) {
+ return console.log(...args.map((string) => chalk.yellow(string)));
+}
+
+export function success(...args) {
+ return console.log(...args.map((string) => chalk.green(string)));
+}
+
+export function askQuestion(question: string) {
+ return new Promise((resolve) => {
+ rl.question(chalk.blueBright(`${question}\n`), resolve);
+ });
+}
diff --git a/scripts/src/templates/tab.ts b/scripts/src/templates/tab.ts
index 5b4101a3e..f76864d67 100644
--- a/scripts/src/templates/tab.ts
+++ b/scripts/src/templates/tab.ts
@@ -1,69 +1,69 @@
-/* eslint-disable no-await-in-loop */
-import { promises as fs } from 'fs';
-
-import { type ModuleManifest, cjsDirname, retrieveManifest } from '../scriptUtils.js';
-
-import { check as _check } from './module.js';
-import { askQuestion, success, warn } from './print.js';
-import { type Options, isPascalCase } from './utilities.js';
-
-export function check(manifest: ModuleManifest, tabName: string) {
- return Object.values(manifest)
- .flatMap((x) => x.tabs)
- .includes(tabName);
-}
-
-async function askModuleName(manifest: ModuleManifest) {
- while (true) {
- const name = await askQuestion('Add a new tab to which module?');
- if (!_check(manifest, name)) {
- warn(`Module ${name} does not exist.`);
- } else {
- return name;
- }
- }
-}
-
-async function askTabName(manifest: ModuleManifest) {
- while (true) {
- const name = await askQuestion(
- 'What is the name of your new tab? (eg. BinaryTree)',
- );
- if (!isPascalCase(name)) {
- warn('Tab names must be in pascal case. (eg. BinaryTree)');
- } else if (check(manifest, name)) {
- warn('A tab with the same name already exists.');
- } else {
- return name;
- }
- }
-}
-
-export async function addNew(buildOpts: Options) {
- const manifest = await retrieveManifest(buildOpts.manifest);
-
- const moduleName = await askModuleName(manifest);
- const tabName = await askTabName(manifest);
-
- // Copy module tab template into correct destination and show success message
- const tabDestination = `${buildOpts.srcDir}/tabs/${tabName}`;
- await fs.mkdir(tabDestination, { recursive: true });
- await fs.copyFile(
- `${cjsDirname(import.meta.url)}/templates/__tab__.tsx`,
- `${tabDestination}/index.tsx`,
- );
- await fs.writeFile(
- 'modules.json',
- JSON.stringify(
- {
- ...manifest,
- [moduleName]: { tabs: [...manifest[moduleName].tabs, tabName] },
- },
- null,
- 2,
- ),
- );
- success(
- `Tab ${tabName} for module ${moduleName} created at ${tabDestination}.`,
- );
-}
+/* eslint-disable no-await-in-loop */
+import { promises as fs } from 'fs';
+
+import { type ModuleManifest, retrieveManifest } from '../scriptUtils.js';
+
+import { check as _check } from './module.js';
+import { askQuestion, success, warn } from './print.js';
+import { type Options, isPascalCase } from './utilities.js';
+
+export function check(manifest: ModuleManifest, tabName: string) {
+ return Object.values(manifest)
+ .flatMap((x) => x.tabs)
+ .includes(tabName);
+}
+
+async function askModuleName(manifest: ModuleManifest) {
+ while (true) {
+ const name = await askQuestion('Add a new tab to which module?');
+ if (!_check(manifest, name)) {
+ warn(`Module ${name} does not exist.`);
+ } else {
+ return name;
+ }
+ }
+}
+
+async function askTabName(manifest: ModuleManifest) {
+ while (true) {
+ const name = await askQuestion(
+ 'What is the name of your new tab? (eg. BinaryTree)',
+ );
+ if (!isPascalCase(name)) {
+ warn('Tab names must be in pascal case. (eg. BinaryTree)');
+ } else if (check(manifest, name)) {
+ warn('A tab with the same name already exists.');
+ } else {
+ return name;
+ }
+ }
+}
+
+export async function addNew(buildOpts: Options) {
+ const manifest = await retrieveManifest(buildOpts.manifest);
+
+ const moduleName = await askModuleName(manifest);
+ const tabName = await askTabName(manifest);
+
+ // Copy module tab template into correct destination and show success message
+ const tabDestination = `${buildOpts.srcDir}/tabs/${tabName}`;
+ await fs.mkdir(tabDestination, { recursive: true });
+ await fs.copyFile(
+ './scripts/src/templates/templates/__tab__.tsx',
+ `${tabDestination}/index.tsx`,
+ );
+ await fs.writeFile(
+ 'modules.json',
+ JSON.stringify(
+ {
+ ...manifest,
+ [moduleName]: { tabs: [...manifest[moduleName].tabs, tabName] },
+ },
+ null,
+ 2,
+ ),
+ );
+ success(
+ `Tab ${tabName} for module ${moduleName} created at ${tabDestination}.`,
+ );
+}
diff --git a/scripts/src/templates/utilities.ts b/scripts/src/templates/utilities.ts
index 62239c25c..449d6e428 100644
--- a/scripts/src/templates/utilities.ts
+++ b/scripts/src/templates/utilities.ts
@@ -1,17 +1,17 @@
-// Snake case regex has been changed from `/\b[a-z]+(?:_[a-z]+)*\b/u` to `/\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u`
-// to be consistent with the naming of the `arcade_2d` and `physics_2d` modules.
-// This change should not affect other modules, since the set of possible names is only expanded.
-const snakeCaseRegex = /\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u;
-const pascalCaseRegex = /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/u;
-
-export function isSnakeCase(string: string) {
- return snakeCaseRegex.test(string);
-}
-
-export function isPascalCase(string: string) {
- return pascalCaseRegex.test(string);
-}
-export type Options = {
- srcDir: string;
- manifest: string;
-};
+// Snake case regex has been changed from `/\b[a-z]+(?:_[a-z]+)*\b/u` to `/\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u`
+// to be consistent with the naming of the `arcade_2d` and `physics_2d` modules.
+// This change should not affect other modules, since the set of possible names is only expanded.
+const snakeCaseRegex = /\b[a-z0-9]+(?:_[a-z0-9]+)*\b/u;
+const pascalCaseRegex = /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/u;
+
+export function isSnakeCase(string: string) {
+ return snakeCaseRegex.test(string);
+}
+
+export function isPascalCase(string: string) {
+ return pascalCaseRegex.test(string);
+}
+export type Options = {
+ srcDir: string;
+ manifest: string;
+};
diff --git a/scripts/src/testing/__tests__/runner.test.ts b/scripts/src/testing/__tests__/runner.test.ts
new file mode 100644
index 000000000..78b606644
--- /dev/null
+++ b/scripts/src/testing/__tests__/runner.test.ts
@@ -0,0 +1,23 @@
+import type { MockedFunction } from 'jest-mock';
+import * as runner from '../runner'
+import getTestCommand from '..'
+
+jest.spyOn(runner, 'runJest').mockImplementation(jest.fn())
+
+const runCommand = (...args: string[]) => getTestCommand().parseAsync(args, { from: 'user' })
+const mockRunJest = runner.runJest as MockedFunction
+
+test('Check that the test command properly passes options to jest', async () => {
+ await runCommand('-u', '-w', '--srcDir', 'gg', './src/folder')
+
+ const [call] = mockRunJest.mock.calls
+ expect(call[0]).toEqual(['-u', '-w', './src/folder'])
+ expect(call[1]).toEqual('gg');
+})
+
+test('Check that the test command handles windows paths as posix paths', async () => {
+ await runCommand('.\\src\\folder')
+
+ const [call] = mockRunJest.mock.calls
+ expect(call[0]).toEqual(['./src/folder'])
+})
\ No newline at end of file
diff --git a/scripts/src/testing/index.ts b/scripts/src/testing/index.ts
new file mode 100644
index 000000000..6f0bb23c7
--- /dev/null
+++ b/scripts/src/testing/index.ts
@@ -0,0 +1,30 @@
+import { Command } from 'commander';
+import lodash from 'lodash';
+import pathlib from 'path';
+
+import { runJest } from './runner.js';
+
+export type TestCommandOptions = {
+ srcDir: string
+};
+
+const getTestCommand = () => new Command('test')
+ .description('Run jest')
+ .option('--srcDir ', 'Source directory for files', 'src')
+ .allowUnknownOption()
+ .action(({ srcDir }: TestCommandOptions, command: Command) => {
+ const [args, filePatterns] = lodash.partition(command.args, (arg) => arg.startsWith('-'));
+
+ // command.args automatically includes the source directory option
+ // which is not supported by Jest, so we need to remove it
+ const toRemove = args.findIndex((arg) => arg.startsWith('--srcDir'));
+ if (toRemove !== -1) {
+ args.splice(toRemove, 1);
+ }
+
+ const jestArgs = args.concat(filePatterns.map((pattern) => pattern.split(pathlib.win32.sep)
+ .join(pathlib.posix.sep)));
+ return runJest(jestArgs, srcDir);
+ });
+
+export default getTestCommand;
diff --git a/scripts/src/testing/runner.ts b/scripts/src/testing/runner.ts
new file mode 100644
index 000000000..4806810eb
--- /dev/null
+++ b/scripts/src/testing/runner.ts
@@ -0,0 +1,6 @@
+import jest from 'jest';
+import pathlib from 'path';
+
+export function runJest(jestArgs: string[], srcDir: string) {
+ return jest.run(jestArgs, pathlib.join(srcDir, 'jest.config.js'));
+}
diff --git a/scripts/src/tsconfig.json b/scripts/src/tsconfig.json
index 90968a629..1c5ca1081 100644
--- a/scripts/src/tsconfig.json
+++ b/scripts/src/tsconfig.json
@@ -5,9 +5,10 @@
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
- "target": "ES2020",
+ "target": "ESNext",
"outDir": "../bin",
-
+ "verbatimModuleSyntax": true,
+ "noEmit": true
},
"exclude": ["./**/__tests__/**/*", "./**/__mocks__/**/*", "./templates/templates/**/*", "./**/jest*"]
}
\ No newline at end of file
diff --git a/src/__mocks__/context.ts b/src/__mocks__/context.ts
new file mode 100644
index 000000000..ce1ec93bf
--- /dev/null
+++ b/src/__mocks__/context.ts
@@ -0,0 +1,5 @@
+export default {
+ moduleContexts: new Proxy({}, {
+ get: () => ({ state: {} })
+ })
+}
\ No newline at end of file
diff --git a/src/bundles/arcade_2d/audio.ts b/src/bundles/arcade_2d/audio.ts
index 1cd3703ec..43f792f7c 100644
--- a/src/bundles/arcade_2d/audio.ts
+++ b/src/bundles/arcade_2d/audio.ts
@@ -1,88 +1,88 @@
-/**
- * This file contains Arcade2D's representation of audio clips and sound.
- */
-
-/**
- * Encapsulates the representation of AudioClips.
- * AudioClips are unique - there are no AudioClips with the same URL.
- */
-export class AudioClip {
- private static audioClipCount: number = 0;
- // Stores AudioClip index with the URL as a unique key.
- private static audioClipsIndexMap: Map = new Map();
- // Stores all the created AudioClips
- private static audioClipsArray: Array = [];
- public readonly id: number;
-
- private isUpdated: boolean = false;
- private shouldPlay: boolean = false;
- private shouldLoop: boolean = false;
-
- private constructor(
- private url: string,
- private volumeLevel: number,
- ) {
- this.id = AudioClip.audioClipCount++;
- AudioClip.audioClipsIndexMap.set(url, this.id);
- AudioClip.audioClipsArray.push(this);
- }
-
- /**
- * Factory method to create new AudioClip if unique URL provided.
- * Otherwise returns the previously created AudioClip.
- */
- public static of(url: string, volumeLevel: number): AudioClip {
- if (url === '') {
- throw new Error('AudioClip URL cannot be empty');
- }
- if (AudioClip.audioClipsIndexMap.has(url)) {
- return AudioClip.audioClipsArray[AudioClip.audioClipsIndexMap.get(url) as number];
- }
- return new AudioClip(url, volumeLevel);
- }
- public getUrl() {
- return this.url;
- }
- public getVolumeLevel() {
- return this.volumeLevel;
- }
- public shouldAudioClipLoop() {
- return this.shouldLoop;
- }
- public shouldAudioClipPlay() {
- return this.shouldPlay;
- }
- public setShouldAudioClipLoop(loop: boolean) {
- if (this.shouldLoop !== loop) {
- this.shouldLoop = loop;
- this.isUpdated = false;
- }
- }
- /**
- * Updates the play/pause state.
- * @param play When true, the Audio Clip has a playing state.
- */
- public setShouldAudioClipPlay(play: boolean) {
- this.shouldPlay = play;
- this.isUpdated = false;
- }
- /**
- * Checks if the Audio Clip needs to update. Updates the flag if true.
- */
- public hasAudioClipUpdates() {
- const prevValue = !this.isUpdated;
- this.setAudioClipUpdated();
- return prevValue;
- }
- public setAudioClipUpdated() {
- this.isUpdated = true;
- }
- public static getAudioClipsArray() {
- return AudioClip.audioClipsArray;
- }
-
- public toReplString = () => '';
-
- /** @override */
- public toString = () => this.toReplString();
-}
+/**
+ * This file contains Arcade2D's representation of audio clips and sound.
+ */
+
+/**
+ * Encapsulates the representation of AudioClips.
+ * AudioClips are unique - there are no AudioClips with the same URL.
+ */
+export class AudioClip {
+ private static audioClipCount: number = 0;
+ // Stores AudioClip index with the URL as a unique key.
+ private static audioClipsIndexMap: Map = new Map();
+ // Stores all the created AudioClips
+ private static audioClipsArray: Array = [];
+ public readonly id: number;
+
+ private isUpdated: boolean = false;
+ private shouldPlay: boolean = false;
+ private shouldLoop: boolean = false;
+
+ private constructor(
+ private url: string,
+ private volumeLevel: number,
+ ) {
+ this.id = AudioClip.audioClipCount++;
+ AudioClip.audioClipsIndexMap.set(url, this.id);
+ AudioClip.audioClipsArray.push(this);
+ }
+
+ /**
+ * Factory method to create new AudioClip if unique URL provided.
+ * Otherwise returns the previously created AudioClip.
+ */
+ public static of(url: string, volumeLevel: number): AudioClip {
+ if (url === '') {
+ throw new Error('AudioClip URL cannot be empty');
+ }
+ if (AudioClip.audioClipsIndexMap.has(url)) {
+ return AudioClip.audioClipsArray[AudioClip.audioClipsIndexMap.get(url) as number];
+ }
+ return new AudioClip(url, volumeLevel);
+ }
+ public getUrl() {
+ return this.url;
+ }
+ public getVolumeLevel() {
+ return this.volumeLevel;
+ }
+ public shouldAudioClipLoop() {
+ return this.shouldLoop;
+ }
+ public shouldAudioClipPlay() {
+ return this.shouldPlay;
+ }
+ public setShouldAudioClipLoop(loop: boolean) {
+ if (this.shouldLoop !== loop) {
+ this.shouldLoop = loop;
+ this.isUpdated = false;
+ }
+ }
+ /**
+ * Updates the play/pause state.
+ * @param play When true, the Audio Clip has a playing state.
+ */
+ public setShouldAudioClipPlay(play: boolean) {
+ this.shouldPlay = play;
+ this.isUpdated = false;
+ }
+ /**
+ * Checks if the Audio Clip needs to update. Updates the flag if true.
+ */
+ public hasAudioClipUpdates() {
+ const prevValue = !this.isUpdated;
+ this.setAudioClipUpdated();
+ return prevValue;
+ }
+ public setAudioClipUpdated() {
+ this.isUpdated = true;
+ }
+ public static getAudioClipsArray() {
+ return AudioClip.audioClipsArray;
+ }
+
+ public toReplString = () => '';
+
+ /** @override */
+ public toString = () => this.toReplString();
+}
diff --git a/src/bundles/arcade_2d/constants.ts b/src/bundles/arcade_2d/constants.ts
index 48bfed350..96b76b991 100644
--- a/src/bundles/arcade_2d/constants.ts
+++ b/src/bundles/arcade_2d/constants.ts
@@ -1,46 +1,46 @@
-// This file contains the default values of the game canvas and GameObjects.
-
-import { type InteractableProps, type RenderProps, type TransformProps } from './types';
-
-// Default values of game
-export const DEFAULT_WIDTH: number = 600;
-export const DEFAULT_HEIGHT: number = 600;
-export const DEFAULT_SCALE: number = 1;
-export const DEFAULT_FPS: number = 30;
-export const DEFAULT_VOLUME: number = 0.5; // Unused
-
-// Interval of allowed values of game
-export const MAX_HEIGHT: number = 1000;
-export const MIN_HEIGHT: number = 100;
-export const MAX_WIDTH: number = 1000;
-export const MIN_WIDTH: number = 100;
-export const MAX_SCALE: number = 10;
-export const MIN_SCALE: number = 0.1;
-export const MAX_FPS: number = 120;
-export const MIN_FPS: number = 1;
-export const MAX_VOLUME: number = 1;
-export const MIN_VOLUME: number = 0;
-
-// A mode where the hitboxes is shown in the canvas for debugging purposes,
-// and debug log information
-export const DEFAULT_DEBUG_STATE: boolean = false;
-
-// Default values for GameObject properties
-export const DEFAULT_TRANSFORM_PROPS: TransformProps = {
- position: [0, 0],
- scale: [1, 1],
- rotation: 0,
-};
-
-export const DEFAULT_RENDER_PROPS: RenderProps = {
- color: [255, 255, 255, 255],
- flip: [false, false],
- isVisible: true,
-};
-
-export const DEFAULT_INTERACTABLE_PROPS: InteractableProps = {
- isHitboxActive: true,
-};
-
-// Default values of Phaser scene
-export const DEFAULT_PATH_PREFIX: string = 'https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/';
+// This file contains the default values of the game canvas and GameObjects.
+
+import { type InteractableProps, type RenderProps, type TransformProps } from './types';
+
+// Default values of game
+export const DEFAULT_WIDTH: number = 600;
+export const DEFAULT_HEIGHT: number = 600;
+export const DEFAULT_SCALE: number = 1;
+export const DEFAULT_FPS: number = 30;
+export const DEFAULT_VOLUME: number = 0.5; // Unused
+
+// Interval of allowed values of game
+export const MAX_HEIGHT: number = 1000;
+export const MIN_HEIGHT: number = 100;
+export const MAX_WIDTH: number = 1000;
+export const MIN_WIDTH: number = 100;
+export const MAX_SCALE: number = 10;
+export const MIN_SCALE: number = 0.1;
+export const MAX_FPS: number = 120;
+export const MIN_FPS: number = 1;
+export const MAX_VOLUME: number = 1;
+export const MIN_VOLUME: number = 0;
+
+// A mode where the hitboxes is shown in the canvas for debugging purposes,
+// and debug log information
+export const DEFAULT_DEBUG_STATE: boolean = false;
+
+// Default values for GameObject properties
+export const DEFAULT_TRANSFORM_PROPS: TransformProps = {
+ position: [0, 0],
+ scale: [1, 1],
+ rotation: 0,
+};
+
+export const DEFAULT_RENDER_PROPS: RenderProps = {
+ color: [255, 255, 255, 255],
+ flip: [false, false],
+ isVisible: true,
+};
+
+export const DEFAULT_INTERACTABLE_PROPS: InteractableProps = {
+ isHitboxActive: true,
+};
+
+// Default values of Phaser scene
+export const DEFAULT_PATH_PREFIX: string = 'https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/';
diff --git a/src/bundles/arcade_2d/functions.ts b/src/bundles/arcade_2d/functions.ts
index f35ec1292..e47236966 100644
--- a/src/bundles/arcade_2d/functions.ts
+++ b/src/bundles/arcade_2d/functions.ts
@@ -1,933 +1,933 @@
-/**
- * The module `arcade_2d` is a wrapper for the Phaser.io game engine.
- * It provides functions for manipulating GameObjects in a canvas.
- *
- * A *GameObject* is defined by its transform and rendered object.
- *
- * @module arcade_2d
- * @author Titus Chew Xuan Jun
- * @author Xenos Fiorenzo Anong
- */
-
-import Phaser from 'phaser';
-import {
- PhaserScene,
- gameState,
-} from './phaserScene';
-import {
- GameObject,
- RenderableGameObject,
- type ShapeGameObject,
- SpriteGameObject,
- TextGameObject,
- RectangleGameObject,
- CircleGameObject,
- TriangleGameObject, InteractableGameObject,
-} from './gameobject';
-import {
- type DisplayText,
- type BuildGame,
- type Sprite,
- type UpdateFunction,
- type RectangleProps,
- type CircleProps,
- type TriangleProps,
- type FlipXY,
- type ScaleXY,
- type PositionXY,
- type DimensionsXY,
- type ColorRGBA,
-} from './types';
-import {
- DEFAULT_WIDTH,
- DEFAULT_HEIGHT,
- DEFAULT_SCALE,
- DEFAULT_FPS,
- MAX_HEIGHT,
- MIN_HEIGHT,
- MAX_WIDTH,
- MIN_WIDTH,
- MAX_SCALE,
- MIN_SCALE,
- MAX_FPS,
- MIN_FPS,
- MAX_VOLUME,
- MIN_VOLUME,
- DEFAULT_DEBUG_STATE,
- DEFAULT_TRANSFORM_PROPS,
- DEFAULT_RENDER_PROPS,
- DEFAULT_INTERACTABLE_PROPS,
-} from './constants';
-import { AudioClip } from './audio';
-
-// =============================================================================
-// Global Variables
-// =============================================================================
-
-// Configuration for game initialization.
-export const config = {
- width: DEFAULT_WIDTH,
- height: DEFAULT_HEIGHT,
- scale: DEFAULT_SCALE,
- fps: DEFAULT_FPS,
- isDebugEnabled: DEFAULT_DEBUG_STATE,
- // User update function
- userUpdateFunction: (() => {}) as UpdateFunction,
-};
-
-// =============================================================================
-// Creation of GameObjects
-// =============================================================================
-
-/**
- * Creates a RectangleGameObject that takes in rectangle shape properties.
- *
- * @param width The width of the rectangle
- * @param height The height of the rectangle
- * @example
- * ```
- * const rectangle = create_rectangle(100, 100);
- * ```
- * @category GameObject
- */
-export const create_rectangle: (width: number, height: number) => ShapeGameObject = (width: number, height: number) => {
- const rectangle = {
- width,
- height,
- } as RectangleProps;
- return new RectangleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, rectangle);
-};
-
-/**
- * Creates a CircleGameObject that takes in circle shape properties.
- *
- * @param width The width of the rectangle
- * @param height The height of the rectangle
- * ```
- * const circle = create_circle(100);
- * ```
- * @category GameObject
- */
-export const create_circle: (radius: number) => ShapeGameObject = (radius: number) => {
- const circle = {
- radius,
- } as CircleProps;
- return new CircleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, circle);
-};
-
-/**
- * Creates a TriangleGameObject that takes in an downright isosceles triangle shape properties.
- *
- * @param width The width of the isosceles triangle
- * @param height The height of the isosceles triangle
- * ```
- * const triangle = create_triangle(100, 100);
- * ```
- * @category GameObject
- */
-export const create_triangle: (width: number, height: number) => ShapeGameObject = (width: number, height: number) => {
- const triangle = {
- x1: 0,
- y1: 0,
- x2: width,
- y2: 0,
- x3: width / 2,
- y3: height,
- } as TriangleProps;
- return new TriangleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, triangle);
-};
-
-/**
- * Creates a GameObject that contains a text reference.
- *
- * @param text Text string displayed
- * @example
- * ```
- * const helloworld = create_text("Hello\nworld!");
- * ```
- * @category GameObject
- */
-export const create_text: (text: string) => TextGameObject = (text: string) => {
- const displayText = {
- text,
- } as DisplayText;
- return new TextGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, displayText);
-};
-
-/**
- * Creates a GameObject that contains a Sprite image reference.
- * Source Academy assets can be used by specifying path without the prepend.
- * Source Academy assets can be found at https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/ with Ctrl+f ".png".
- * Phaser assets can be found at https://labs.phaser.io/assets/.
- * If Phaser assets are unavailable, go to https://github.com/photonstorm/phaser3-examples/tree/master/public/assets
- * to get the asset path and append it to `https://labs.phaser.io/assets/`.
- * Assets from other websites can also be used if they support Cross-Origin Resource Sharing (CORS), but the full path must be specified.
- *
- * @param image_url The image URL of the sprite
- * @example
- * ```
- * const shortpath = create_sprite("objects/cmr/splendall.png");
- * const fullpath = create_sprite("https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/objects/cmr/splendall.png");
- * ```
- * @category GameObject
- */
-export const create_sprite: (image_url: string) => SpriteGameObject = (image_url: string) => {
- if (image_url === '') {
- throw new Error('image_url cannot be empty');
- }
- if (typeof image_url !== 'string') {
- throw new Error('image_url must be a string');
- }
- const sprite: Sprite = {
- imageUrl: image_url,
- } as Sprite;
- return new SpriteGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, sprite);
-};
-
-// =============================================================================
-// Manipulation of GameObjects
-// =============================================================================
-
-/**
- * Updates the position transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @param coordinates [x, y] coordinates of new position
- * @returns the GameObject reference passed in
- * @example
- * ```
- * update_position(create_text("Hello world!"), [1, 2]);
- * ```
- * @category GameObject
- */
-export const update_position: (gameObject: GameObject, [x, y]: PositionXY) => GameObject
-= (gameObject: GameObject, [x, y]: PositionXY) => {
- if (gameObject instanceof GameObject) {
- gameObject.setTransform({
- ...gameObject.getTransform(),
- position: [x, y],
- });
- return gameObject;
- }
- throw new TypeError('Cannot update position of a non-GameObject');
-};
-
-/**
- * Updates the scale transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @param scale [x, y] scale of the size of the GameObject
- * @returns the GameObject reference passed in
- * @example
- * ```
- * update_scale(create_text("Hello world!"), [2, 0.5]);
- * ```
- * @category GameObject
- */
-export const update_scale: (gameObject: GameObject, [x, y]: ScaleXY) => GameObject
-= (gameObject: GameObject, [x, y]: ScaleXY) => {
- if (gameObject instanceof GameObject) {
- gameObject.setTransform({
- ...gameObject.getTransform(),
- scale: [x, y],
- });
- return gameObject;
- }
- throw new TypeError('Cannot update scale of a non-GameObject');
-};
-
-/**
- * Updates the rotation transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @param radians The value in radians to rotate the GameObject clockwise by
- * @returns the GameObject reference passed in
- * @example
- * ```
- * update_rotation(create_text("Hello world!"), math_PI);
- * ```
- * @category GameObject
- */
-export const update_rotation: (gameObject: GameObject, radians: number) => GameObject
-= (gameObject: GameObject, radians: number) => {
- if (gameObject instanceof GameObject) {
- gameObject.setTransform({
- ...gameObject.getTransform(),
- rotation: radians,
- });
- return gameObject;
- }
- throw new TypeError('Cannot update rotation of a non-GameObject');
-};
-
-/**
- * Updates the color of the GameObject.
- * Note that the value is modulo 256, so passing values greater than 255 is allowed.
- *
- * @param gameObject GameObject reference
- * @param color The color as an RGBA array, with RGBA values ranging from 0 to 255.
- * @returns the GameObject reference passed in
- * @example
- * ```
- * update_color(create_rectangle(100, 100), [255, 0, 0, 255]);
- * ```
- * @category GameObject
- */
-export const update_color: (gameObject: GameObject, color: ColorRGBA) => GameObject
-= (gameObject: GameObject, color: ColorRGBA) => {
- if (color.length !== 4) {
- throw new Error('color must be a 4-element array');
- }
- if (gameObject instanceof RenderableGameObject) {
- gameObject.setRenderState({
- ...gameObject.getRenderState(),
- color,
- });
- return gameObject;
- }
- throw new TypeError('Cannot update color of a non-GameObject');
-};
-
-/**
- * Updates the flip state of the GameObject.
- *
- * @param gameObject GameObject reference
- * @param flip The [x, y] flip state as a boolean array
- * @returns the GameObject reference passed in
- * @example
- * ```
- * update_flip(create_triangle(100, 100), [false, true]);
- * ```
- * @category GameObject
- */
-export const update_flip: (gameObject: GameObject, flip: FlipXY) => GameObject
-= (gameObject: GameObject, flip: FlipXY) => {
- if (flip.length !== 2) {
- throw new Error('flip must be a 2-element array');
- }
- if (gameObject instanceof RenderableGameObject) {
- gameObject.setRenderState({
- ...gameObject.getRenderState(),
- flip,
- });
- return gameObject;
- }
- throw new TypeError('Cannot update flip of a non-GameObject');
-};
-
-/**
- * Updates the text of the TextGameObject.
- *
- * @param textGameObject TextGameObject reference
- * @param text The updated text of the TextGameObject
- * @returns the GameObject reference passed in
- * @throws Error if not a TextGameObject is passed in
- * @example
- * ```
- * update_text(create_text("Hello world!"), "Goodbye world!");
- * ```
- * @category GameObject
- */
-export const update_text: (textGameObject: TextGameObject, text: string) => GameObject
-= (textGameObject: TextGameObject, text: string) => {
- if (textGameObject instanceof TextGameObject) {
- textGameObject.setText({
- text,
- } as DisplayText);
- return textGameObject;
- }
- throw new TypeError('Cannot update text onto a non-TextGameObject');
-};
-
-/**
- * Renders this GameObject in front of all other GameObjects.
- *
- * @param gameObject GameObject reference
- * @example
- * ```
- * update_to_top(create_text("Hello world!"));
- * ```
- * @category GameObject
- */
-export const update_to_top: (gameObject: GameObject) => GameObject
-= (gameObject: GameObject) => {
- if (gameObject instanceof RenderableGameObject) {
- gameObject.setBringToTopFlag();
- return gameObject;
- }
- throw new TypeError('Cannot update to top a non-GameObject');
-};
-
-// =============================================================================
-// Querying of GameObjects
-// =============================================================================
-
-/**
- * Queries the id of the GameObject.
- * The id of a GameObject is in the order of creation, starting from 0.
- *
- * @param gameObject GameObject reference
- * @returns the id of the GameObject reference
- * @example
- * ```
- * const id0 = create_text("This has id 0");
- * const id1 = create_text("This has id 1");
- * const id2 = create_text("This has id 2");
- * queryGameObjectId(id2);
- * ```
- * @category GameObject
- */
-export const query_id: (gameObject: GameObject) => number = (gameObject: GameObject) => {
- if (gameObject instanceof GameObject) {
- return gameObject.id;
- }
- throw new TypeError('Cannot query id of non-GameObject');
-};
-
-/**
- * Queries the [x, y] position transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @returns [x, y] position as an array
- * @example
- * ```
- * const gameobject = update_position(create_circle(100), [100, 100]);
- * query_position(gameobject);
- * ```
- * @category GameObject
- */
-export const query_position: (gameObject: GameObject) => PositionXY
-= (gameObject: GameObject) => {
- if (gameObject instanceof GameObject) {
- return [...gameObject.getTransform().position];
- }
- throw new TypeError('Cannot query position of non-GameObject');
-};
-
-/**
- * Queries the z-rotation transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @returns z-rotation as a number in radians
- * @example
- * ```
- * const gameobject = update_rotation(create_rectangle(100, 200), math_PI / 4);
- * query_rotation(gameobject);
- * ```
- * @category GameObject
- */
-export const query_rotation: (gameObject: GameObject) => number
-= (gameObject: GameObject) => {
- if (gameObject instanceof GameObject) {
- return gameObject.getTransform().rotation;
- }
- throw new TypeError('Cannot query rotation of non-GameObject');
-};
-
-/**
- * Queries the [x, y] scale transform of the GameObject.
- *
- * @param gameObject GameObject reference
- * @returns [x, y] scale as an array
- * @example
- * ```
- * const gameobject = update_scale(create_circle(100), [2, 0.5]);
- * query_scale(gameobject);
- * ```
- * @category GameObject
- */
-export const query_scale: (gameObject: GameObject) => ScaleXY
-= (gameObject: GameObject) => {
- if (gameObject instanceof GameObject) {
- return [...gameObject.getTransform().scale];
- }
- throw new TypeError('Cannot query scale of non-GameObject');
-};
-
-/**
- * Queries the [r, g, b, a] color property of the GameObject.
- *
- * @param gameObject GameObject reference
- * @returns [r, g, b, a] color as an array
- * @example
- * ```
- * const gameobject = update_color(create_circle(100), [255, 127, 127, 255]);
- * query_color(gameobject);
- * ```
- * @category GameObject
- */
-export const query_color: (gameObject: RenderableGameObject) => ColorRGBA
-= (gameObject: RenderableGameObject) => {
- if (gameObject instanceof RenderableGameObject) {
- return [...gameObject.getColor()];
- }
- throw new TypeError('Cannot query color of non-GameObject');
-};
-
-/**
- * Queries the [x, y] flip property of the GameObject.
- *
- * @param gameObject GameObject reference
- * @returns [x, y] flip state as an array
- * @example
- * ```
- * const gameobject = update_flip(create_triangle(100), [false, true]);
- * query_flip(gameobject);
- * ```
- * @category GameObject
- */
-export const query_flip: (gameObject: RenderableGameObject) => FlipXY
-= (gameObject: RenderableGameObject) => {
- if (gameObject instanceof RenderableGameObject) {
- return [...gameObject.getFlipState()];
- }
- throw new TypeError('Cannot query flip of non-GameObject');
-};
-
-/**
- * Queries the text of a Text GameObject.
- *
- * @param textGameObject TextGameObject reference
- * @returns text string associated with the Text GameObject
- * @throws Error if not a TextGameObject is passed in
- * @example
- * ```
- * const text = create_text("Hello World!");
- * query_text(text);
- * ```
- * @category GameObject
- */
-export const query_text: (textGameObject: TextGameObject) => string
-= (textGameObject: TextGameObject) => {
- if (textGameObject instanceof TextGameObject) {
- return textGameObject.getText().text;
- }
- throw new TypeError('Cannot query text of non-TextGameObject');
-};
-
-/**
- * Queries the (mouse) pointer position.
- *
- * @returns [x, y] coordinates of the pointer as an array
- * @example
- * ```
- * const position = query_pointer_position();
- * position[0]; // x
- * position[1]; // y
- * ```
- */
-export const query_pointer_position: () => PositionXY
-= () => gameState.pointerProps.pointerPosition;
-
-// =============================================================================
-// Game configuration
-// =============================================================================
-
-/**
- * Private function to set the allowed range for a value.
- *
- * @param num the numeric value
- * @param min the minimum value allowed for that number
- * @param max the maximum value allowed for that number
- * @returns a number within the interval
- * @hidden
- */
-const withinRange: (num: number, min: number, max: number) => number
-= (num: number, min: number, max: number) => {
- if (num > max) {
- return max;
- }
- if (num < min) {
- return min;
- }
- return num;
-};
-
-/**
- * Sets the frames per second of the canvas, which should be between the MIN_FPS and MAX_FPS.
- * It ranges between 1 and 120, with the default target as 30.
- * This function should not be called in the update function.
- *
- * @param fps The frames per second of canvas to set.
- * @example
- * ```
- * // set fps to 60
- * set_fps(60);
- * ```
- */
-export const set_fps: (fps: number) => void = (fps: number) => {
- config.fps = withinRange(fps, MIN_FPS, MAX_FPS);
-};
-
-/**
- * Sets the dimensions of the canvas, which should be between the
- * min and max widths and height.
- *
- * @param dimensions An array containing [width, height] of the canvas.
- * @example
- * ```
- * // set the width as 500 and height as 400
- * set_dimensions([500, 400]);
- * ```
- */
-export const set_dimensions: (dimensions: DimensionsXY) => void = (dimensions: DimensionsXY) => {
- if (dimensions.length !== 2) {
- throw new Error('dimensions must be a 2-element array');
- }
- config.width = withinRange(dimensions[0], MIN_WIDTH, MAX_WIDTH);
- config.height = withinRange(dimensions[1], MIN_HEIGHT, MAX_HEIGHT);
-};
-
-/**
- * Sets the scale (zoom) of the pixels in the canvas.
- * If scale is doubled, then the number of units across would be halved.
- * This has a side effect of making the game pixelated if scale > 1.
- * The default scale is 1.
- *
- * @param scale The scale of the canvas to set.
- * @example
- * ```
- * // sets the scale of the canvas to 2.
- * set_scale(2);
- * ```
- */
-export const set_scale: (scale: number) => void = (scale: number) => {
- config.scale = withinRange(scale, MIN_SCALE, MAX_SCALE);
-};
-
-/**
- * Enables debug mode.
- * Hit box interaction between pointer and GameObjects are shown with a green outline in debug mode.
- * Hit box interaction between GameObjects is based off a rectangular area instead, which is not reflected.
- * debug_log(...) information is shown on the top-left corner of the canvas.
- *
- * @example
- * ```
- * enable_debug();
- * update_loop(game_state => {
- * debug_log(get_game_time());
- * });
- * ```
- */
-export const enable_debug: () => void = () => {
- config.isDebugEnabled = true;
-};
-
-
-/**
- * Logs any information passed into it within the `update_loop`.
- * Displays the information in the top-left corner of the canvas only if debug mode is enabled.
- * Calling `display` within the `update_loop` function will not work as intended, so use `debug_log` instead.
- *
- * @param info The information to log.
- * @example
- * ```
- * enable_debug();
- * update_loop(game_state => {
- * debug_log(get_game_time());
- * });
- * ```
- */
-export const debug_log: (info: string) => void = (info: string) => {
- if (config.isDebugEnabled) {
- gameState.debugLogArray.push(info);
- }
-};
-
-// =============================================================================
-// Game loop
-// =============================================================================
-
-/**
- * Detects if a key input is pressed down.
- * This function must be called in your update function to detect inputs.
- * To get specific keys, go to https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key#result.
- *
- * @param key_name The key name of the key.
- * @returns True, in the frame the key is pressed down.
- * @example
- * ```
- * if (input_key_down("a")) {
- * // "a" key is pressed down
- * }
- * ```
- * @category Logic
- */
-export const input_key_down: (key_name: string) => boolean = (key_name: string) => gameState.inputKeysDown.has(key_name);
-
-/**
- * Detects if the left mouse button is pressed down.
- * This function should be called in your update function.
- *
- * @returns True, if the left mouse button is pressed down.
- * @example
- * ```
- * if(input_left_mouse_down()) {
- * // Left mouse button down
- * }
- * ```
- * @category Logic
- */
-export const input_left_mouse_down: () => boolean = () => gameState.pointerProps.isPointerPrimaryDown;
-
-/**
- * Detects if the right mouse button is pressed down.
- * This function should be called in your update function.
- *
- * @returns True, if the right mouse button is pressed down.
- * @example
- * ```
- * if (input_right_mouse_down()) {
- * // Right mouse button down
- * }
- * ```
- * @category Logic
- */
-export const input_right_mouse_down: () => boolean = () => gameState.pointerProps.isPointerSecondaryDown;
-
-/**
- * Detects if the (mouse) pointer is over the gameobject.
- * This function should be called in your update function.
- *
- * @param gameObject The gameobject reference.
- * @returns True, if the pointer is over the gameobject.
- * @example
- * ```
- * // Creating a button using a gameobject
- * const button = createTextGameObject("click me");
- * // Test if button is clicked
- * if (pointer_over_gameobject(button) && input_left_mouse_down()) {
- * // Button is clicked
- * }
- * ```
- * @category Logic
- */
-export const pointer_over_gameobject = (gameObject: GameObject) => {
- if (gameObject instanceof GameObject) {
- return gameState.pointerProps.pointerOverGameObjectsId.has(gameObject.id);
- }
- throw new TypeError('Cannot check pointer over non-GameObject');
-};
-/**
- * Checks if two gameobjects overlap with each other, using a rectangular bounding box.
- * This bounding box is rectangular, for all GameObjects.
- * This function should be called in your update function.
- *
- * @param gameObject1 The first gameobject reference.
- * @param gameObject2 The second gameobject reference.
- * @returns True, if both gameobjects overlap with each other.
- * @example
- * ```
- * const rectangle1 = create_rectangle(100, 100);
- * const rectangle2 = create_rectangle(100, 100);
- * if (gameobjects_overlap(rectangle1, rectangle2)) {
- * // Both rectangles overlap
- * }
- * ```
- * @category Logic
- */
-export const gameobjects_overlap: (gameObject1: InteractableGameObject, gameObject2: InteractableGameObject) => boolean
-= (gameObject1: InteractableGameObject, gameObject2: InteractableGameObject) => {
- if (gameObject1 instanceof InteractableGameObject && gameObject2 instanceof InteractableGameObject) {
- return gameObject1.isOverlapping(gameObject2);
- }
- throw new TypeError('Cannot check overlap of non-GameObject');
-};
-/**
- * Gets the current in-game time, which is based off the start time.
- * This function should be called in your update function.
- *
- * @returns a number specifying the time in milliseconds
- * @example
- * ```
- * if (get_game_time() > 100) {
- * // Do something after 100 milliseconds
- * }
- * ```
- */
-export const get_game_time: () => number = () => gameState.gameTime;
-
-/**
- * Gets the current loop count, which is the number of frames that have run.
- * Depends on the framerate set for how fast this changes.
- * This function should be called in your update function.
- *
- * @returns a number specifying number of loops that have been run.
- * @example
- * ```
- * if (get_loop_count() === 100) {
- * // Do something on the 100th frame
- * }
- * ```
- */
-export const get_loop_count: () => number = () => gameState.loopCount;
-
-/**
- * This sets the update loop in the canvas.
- * The update loop is run once per frame, so it depends on the fps set for the number of times this loop is run.
- * There should only be one update_loop called.
- * All game logic should be handled within your update_function.
- * You cannot create GameObjects inside the update_loop.
- * game_state is an array that can be modified to store anything.
- *
- * @param update_function A user-defined update_function, that takes in an array as a parameter.
- * @example
- * ```
- * // Create gameobjects outside update_loop
- * update_loop((game_state) => {
- * // Update gameobjects inside update_loop
- *
- * // Using game_state as a counter
- * if (game_state[0] === undefined) {
- * game_state[0] = 0;
- * }
- * game_state[0] = game_state[0] + 1;
- * })
- * ```
- */
-export const update_loop: (update_function: UpdateFunction) => void = (update_function: UpdateFunction) => {
- // Test for error in user update function
- // This cannot not check for errors inside a block that is not run.
- update_function([]);
- config.userUpdateFunction = update_function;
-};
-
-/**
- * Builds the game.
- * Processes the initialization and updating of the game.
- * All created GameObjects and their properties are passed into the game.
- *
- * @example
- * ```
- * // This must be the last function called in the Source program.
- * build_game();
- * ```
- */
-export const build_game: () => BuildGame = () => {
- // Reset frame and time counters.
- gameState.loopCount = 0;
- gameState.gameTime = 0;
-
- const inputConfig = {
- keyboard: true,
- mouse: true,
- windowEvents: false,
- };
-
- const fpsConfig = {
- min: MIN_FPS,
- target: config.fps,
- forceSetTimeOut: true,
- };
-
- const gameConfig = {
- width: config.width / config.scale,
- height: config.height / config.scale,
- zoom: config.scale,
- // Setting to Phaser.WEBGL can lead to WebGL: INVALID_OPERATION errors, so Phaser.CANVAS is used instead.
- // Also: Phaser.WEBGL can crash when there are too many contexts
- // WEBGL is generally more performant, and allows for tinting of gameobjects.
- type: Phaser.WEBGL,
- parent: 'phaser-game',
- scene: PhaserScene,
- input: inputConfig,
- fps: fpsConfig,
- banner: false,
- };
-
- return {
- toReplString: () => '[Arcade 2D]',
- gameConfig,
- };
-};
-
-// =============================================================================
-// Audio functions
-// =============================================================================
-
-/**
- * Create an audio clip that can be referenced.
- * Source Academy assets can be found at https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/ with Ctrl+f ".mp3".
- * Phaser audio assets can be found at https://labs.phaser.io/assets/audio.
- * Phaser sound effects assets can be found at https://labs.phaser.io/assets/audio/SoundEffects/.
- * If Phaser assets are unavailable, go to https://github.com/photonstorm/phaser3-examples/tree/master/public/assets
- * to get the asset path and append it to `https://labs.phaser.io/assets/`.
- * This function should not be called in your update function.
- *
- * @param audio_url The URL of the audio clip.
- * @param volume_level A number between 0 to 1, representing the volume level of the audio clip.
- * @returns The AudioClip reference
- * @example
- * ```
- * const audioClip = create_audio("bgm/GalacticHarmony.mp3", 0.5);
- * ```
- * @category Audio
- */
-export const create_audio: (audio_url: string, volume_level: number) => AudioClip
-= (audio_url: string, volume_level: number) => {
- if (typeof audio_url !== 'string') {
- throw new Error('audio_url must be a string');
- }
- if (typeof volume_level !== 'number') {
- throw new Error('volume_level must be a number');
- }
- return AudioClip.of(audio_url, withinRange(volume_level, MIN_VOLUME, MAX_VOLUME));
-};
-
-/**
- * Loops the audio clip provided, which will play the audio clip indefinitely.
- * Setting whether an audio clip should loop be done outside the update function.
- *
- * @param audio_clip The AudioClip reference
- * @returns The AudioClip reference
- * @example
- * ```
- * const audioClip = loop_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
- * ```
- * @category Audio
- */
-export const loop_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
- if (audio_clip instanceof AudioClip) {
- audio_clip.setShouldAudioClipLoop(true);
- return audio_clip;
- }
- throw new TypeError('Cannot loop a non-AudioClip');
-};
-
-/**
- * Plays the audio clip, and stops when the audio clip is over.
- *
- * @param audio_clip The AudioClip reference
- * @returns The AudioClip reference
- * @example
- * ```
- * const audioClip = play_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
- * ```
- * @category Audio
- */
-export const play_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
- if (audio_clip instanceof AudioClip) {
- audio_clip.setShouldAudioClipPlay(true);
- return audio_clip;
- }
- throw new TypeError('Cannot play a non-AudioClip');
-};
-
-/**
- * Stops the audio clip immediately.
- *
- * @param audio_clip The AudioClip reference
- * @returns The AudioClip reference
- * @example
- * ```
- * const audioClip = play_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
- * ```
- * @category Audio
- */
-export const stop_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
- if (audio_clip instanceof AudioClip) {
- audio_clip.setShouldAudioClipPlay(false);
- return audio_clip;
- }
- throw new TypeError('Cannot stop a non-AudioClip');
-};
+/**
+ * The module `arcade_2d` is a wrapper for the Phaser.io game engine.
+ * It provides functions for manipulating GameObjects in a canvas.
+ *
+ * A *GameObject* is defined by its transform and rendered object.
+ *
+ * @module arcade_2d
+ * @author Titus Chew Xuan Jun
+ * @author Xenos Fiorenzo Anong
+ */
+
+import Phaser from 'phaser';
+import {
+ PhaserScene,
+ gameState,
+} from './phaserScene';
+import {
+ GameObject,
+ RenderableGameObject,
+ type ShapeGameObject,
+ SpriteGameObject,
+ TextGameObject,
+ RectangleGameObject,
+ CircleGameObject,
+ TriangleGameObject, InteractableGameObject,
+} from './gameobject';
+import {
+ type DisplayText,
+ type BuildGame,
+ type Sprite,
+ type UpdateFunction,
+ type RectangleProps,
+ type CircleProps,
+ type TriangleProps,
+ type FlipXY,
+ type ScaleXY,
+ type PositionXY,
+ type DimensionsXY,
+ type ColorRGBA,
+} from './types';
+import {
+ DEFAULT_WIDTH,
+ DEFAULT_HEIGHT,
+ DEFAULT_SCALE,
+ DEFAULT_FPS,
+ MAX_HEIGHT,
+ MIN_HEIGHT,
+ MAX_WIDTH,
+ MIN_WIDTH,
+ MAX_SCALE,
+ MIN_SCALE,
+ MAX_FPS,
+ MIN_FPS,
+ MAX_VOLUME,
+ MIN_VOLUME,
+ DEFAULT_DEBUG_STATE,
+ DEFAULT_TRANSFORM_PROPS,
+ DEFAULT_RENDER_PROPS,
+ DEFAULT_INTERACTABLE_PROPS,
+} from './constants';
+import { AudioClip } from './audio';
+
+// =============================================================================
+// Global Variables
+// =============================================================================
+
+// Configuration for game initialization.
+export const config = {
+ width: DEFAULT_WIDTH,
+ height: DEFAULT_HEIGHT,
+ scale: DEFAULT_SCALE,
+ fps: DEFAULT_FPS,
+ isDebugEnabled: DEFAULT_DEBUG_STATE,
+ // User update function
+ userUpdateFunction: (() => {}) as UpdateFunction,
+};
+
+// =============================================================================
+// Creation of GameObjects
+// =============================================================================
+
+/**
+ * Creates a RectangleGameObject that takes in rectangle shape properties.
+ *
+ * @param width The width of the rectangle
+ * @param height The height of the rectangle
+ * @example
+ * ```
+ * const rectangle = create_rectangle(100, 100);
+ * ```
+ * @category GameObject
+ */
+export const create_rectangle: (width: number, height: number) => ShapeGameObject = (width: number, height: number) => {
+ const rectangle = {
+ width,
+ height,
+ } as RectangleProps;
+ return new RectangleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, rectangle);
+};
+
+/**
+ * Creates a CircleGameObject that takes in circle shape properties.
+ *
+ * @param width The width of the rectangle
+ * @param height The height of the rectangle
+ * ```
+ * const circle = create_circle(100);
+ * ```
+ * @category GameObject
+ */
+export const create_circle: (radius: number) => ShapeGameObject = (radius: number) => {
+ const circle = {
+ radius,
+ } as CircleProps;
+ return new CircleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, circle);
+};
+
+/**
+ * Creates a TriangleGameObject that takes in an downright isosceles triangle shape properties.
+ *
+ * @param width The width of the isosceles triangle
+ * @param height The height of the isosceles triangle
+ * ```
+ * const triangle = create_triangle(100, 100);
+ * ```
+ * @category GameObject
+ */
+export const create_triangle: (width: number, height: number) => ShapeGameObject = (width: number, height: number) => {
+ const triangle = {
+ x1: 0,
+ y1: 0,
+ x2: width,
+ y2: 0,
+ x3: width / 2,
+ y3: height,
+ } as TriangleProps;
+ return new TriangleGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, triangle);
+};
+
+/**
+ * Creates a GameObject that contains a text reference.
+ *
+ * @param text Text string displayed
+ * @example
+ * ```
+ * const helloworld = create_text("Hello\nworld!");
+ * ```
+ * @category GameObject
+ */
+export const create_text: (text: string) => TextGameObject = (text: string) => {
+ const displayText = {
+ text,
+ } as DisplayText;
+ return new TextGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, displayText);
+};
+
+/**
+ * Creates a GameObject that contains a Sprite image reference.
+ * Source Academy assets can be used by specifying path without the prepend.
+ * Source Academy assets can be found at https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/ with Ctrl+f ".png".
+ * Phaser assets can be found at https://labs.phaser.io/assets/.
+ * If Phaser assets are unavailable, go to https://github.com/photonstorm/phaser3-examples/tree/master/public/assets
+ * to get the asset path and append it to `https://labs.phaser.io/assets/`.
+ * Assets from other websites can also be used if they support Cross-Origin Resource Sharing (CORS), but the full path must be specified.
+ *
+ * @param image_url The image URL of the sprite
+ * @example
+ * ```
+ * const shortpath = create_sprite("objects/cmr/splendall.png");
+ * const fullpath = create_sprite("https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/objects/cmr/splendall.png");
+ * ```
+ * @category GameObject
+ */
+export const create_sprite: (image_url: string) => SpriteGameObject = (image_url: string) => {
+ if (image_url === '') {
+ throw new Error('image_url cannot be empty');
+ }
+ if (typeof image_url !== 'string') {
+ throw new Error('image_url must be a string');
+ }
+ const sprite: Sprite = {
+ imageUrl: image_url,
+ } as Sprite;
+ return new SpriteGameObject(DEFAULT_TRANSFORM_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_INTERACTABLE_PROPS, sprite);
+};
+
+// =============================================================================
+// Manipulation of GameObjects
+// =============================================================================
+
+/**
+ * Updates the position transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @param coordinates [x, y] coordinates of new position
+ * @returns the GameObject reference passed in
+ * @example
+ * ```
+ * update_position(create_text("Hello world!"), [1, 2]);
+ * ```
+ * @category GameObject
+ */
+export const update_position: (gameObject: GameObject, [x, y]: PositionXY) => GameObject
+= (gameObject: GameObject, [x, y]: PositionXY) => {
+ if (gameObject instanceof GameObject) {
+ gameObject.setTransform({
+ ...gameObject.getTransform(),
+ position: [x, y],
+ });
+ return gameObject;
+ }
+ throw new TypeError('Cannot update position of a non-GameObject');
+};
+
+/**
+ * Updates the scale transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @param scale [x, y] scale of the size of the GameObject
+ * @returns the GameObject reference passed in
+ * @example
+ * ```
+ * update_scale(create_text("Hello world!"), [2, 0.5]);
+ * ```
+ * @category GameObject
+ */
+export const update_scale: (gameObject: GameObject, [x, y]: ScaleXY) => GameObject
+= (gameObject: GameObject, [x, y]: ScaleXY) => {
+ if (gameObject instanceof GameObject) {
+ gameObject.setTransform({
+ ...gameObject.getTransform(),
+ scale: [x, y],
+ });
+ return gameObject;
+ }
+ throw new TypeError('Cannot update scale of a non-GameObject');
+};
+
+/**
+ * Updates the rotation transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @param radians The value in radians to rotate the GameObject clockwise by
+ * @returns the GameObject reference passed in
+ * @example
+ * ```
+ * update_rotation(create_text("Hello world!"), math_PI);
+ * ```
+ * @category GameObject
+ */
+export const update_rotation: (gameObject: GameObject, radians: number) => GameObject
+= (gameObject: GameObject, radians: number) => {
+ if (gameObject instanceof GameObject) {
+ gameObject.setTransform({
+ ...gameObject.getTransform(),
+ rotation: radians,
+ });
+ return gameObject;
+ }
+ throw new TypeError('Cannot update rotation of a non-GameObject');
+};
+
+/**
+ * Updates the color of the GameObject.
+ * Note that the value is modulo 256, so passing values greater than 255 is allowed.
+ *
+ * @param gameObject GameObject reference
+ * @param color The color as an RGBA array, with RGBA values ranging from 0 to 255.
+ * @returns the GameObject reference passed in
+ * @example
+ * ```
+ * update_color(create_rectangle(100, 100), [255, 0, 0, 255]);
+ * ```
+ * @category GameObject
+ */
+export const update_color: (gameObject: GameObject, color: ColorRGBA) => GameObject
+= (gameObject: GameObject, color: ColorRGBA) => {
+ if (color.length !== 4) {
+ throw new Error('color must be a 4-element array');
+ }
+ if (gameObject instanceof RenderableGameObject) {
+ gameObject.setRenderState({
+ ...gameObject.getRenderState(),
+ color,
+ });
+ return gameObject;
+ }
+ throw new TypeError('Cannot update color of a non-GameObject');
+};
+
+/**
+ * Updates the flip state of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @param flip The [x, y] flip state as a boolean array
+ * @returns the GameObject reference passed in
+ * @example
+ * ```
+ * update_flip(create_triangle(100, 100), [false, true]);
+ * ```
+ * @category GameObject
+ */
+export const update_flip: (gameObject: GameObject, flip: FlipXY) => GameObject
+= (gameObject: GameObject, flip: FlipXY) => {
+ if (flip.length !== 2) {
+ throw new Error('flip must be a 2-element array');
+ }
+ if (gameObject instanceof RenderableGameObject) {
+ gameObject.setRenderState({
+ ...gameObject.getRenderState(),
+ flip,
+ });
+ return gameObject;
+ }
+ throw new TypeError('Cannot update flip of a non-GameObject');
+};
+
+/**
+ * Updates the text of the TextGameObject.
+ *
+ * @param textGameObject TextGameObject reference
+ * @param text The updated text of the TextGameObject
+ * @returns the GameObject reference passed in
+ * @throws Error if not a TextGameObject is passed in
+ * @example
+ * ```
+ * update_text(create_text("Hello world!"), "Goodbye world!");
+ * ```
+ * @category GameObject
+ */
+export const update_text: (textGameObject: TextGameObject, text: string) => GameObject
+= (textGameObject: TextGameObject, text: string) => {
+ if (textGameObject instanceof TextGameObject) {
+ textGameObject.setText({
+ text,
+ } as DisplayText);
+ return textGameObject;
+ }
+ throw new TypeError('Cannot update text onto a non-TextGameObject');
+};
+
+/**
+ * Renders this GameObject in front of all other GameObjects.
+ *
+ * @param gameObject GameObject reference
+ * @example
+ * ```
+ * update_to_top(create_text("Hello world!"));
+ * ```
+ * @category GameObject
+ */
+export const update_to_top: (gameObject: GameObject) => GameObject
+= (gameObject: GameObject) => {
+ if (gameObject instanceof RenderableGameObject) {
+ gameObject.setBringToTopFlag();
+ return gameObject;
+ }
+ throw new TypeError('Cannot update to top a non-GameObject');
+};
+
+// =============================================================================
+// Querying of GameObjects
+// =============================================================================
+
+/**
+ * Queries the id of the GameObject.
+ * The id of a GameObject is in the order of creation, starting from 0.
+ *
+ * @param gameObject GameObject reference
+ * @returns the id of the GameObject reference
+ * @example
+ * ```
+ * const id0 = create_text("This has id 0");
+ * const id1 = create_text("This has id 1");
+ * const id2 = create_text("This has id 2");
+ * queryGameObjectId(id2);
+ * ```
+ * @category GameObject
+ */
+export const query_id: (gameObject: GameObject) => number = (gameObject: GameObject) => {
+ if (gameObject instanceof GameObject) {
+ return gameObject.id;
+ }
+ throw new TypeError('Cannot query id of non-GameObject');
+};
+
+/**
+ * Queries the [x, y] position transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @returns [x, y] position as an array
+ * @example
+ * ```
+ * const gameobject = update_position(create_circle(100), [100, 100]);
+ * query_position(gameobject);
+ * ```
+ * @category GameObject
+ */
+export const query_position: (gameObject: GameObject) => PositionXY
+= (gameObject: GameObject) => {
+ if (gameObject instanceof GameObject) {
+ return [...gameObject.getTransform().position];
+ }
+ throw new TypeError('Cannot query position of non-GameObject');
+};
+
+/**
+ * Queries the z-rotation transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @returns z-rotation as a number in radians
+ * @example
+ * ```
+ * const gameobject = update_rotation(create_rectangle(100, 200), math_PI / 4);
+ * query_rotation(gameobject);
+ * ```
+ * @category GameObject
+ */
+export const query_rotation: (gameObject: GameObject) => number
+= (gameObject: GameObject) => {
+ if (gameObject instanceof GameObject) {
+ return gameObject.getTransform().rotation;
+ }
+ throw new TypeError('Cannot query rotation of non-GameObject');
+};
+
+/**
+ * Queries the [x, y] scale transform of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @returns [x, y] scale as an array
+ * @example
+ * ```
+ * const gameobject = update_scale(create_circle(100), [2, 0.5]);
+ * query_scale(gameobject);
+ * ```
+ * @category GameObject
+ */
+export const query_scale: (gameObject: GameObject) => ScaleXY
+= (gameObject: GameObject) => {
+ if (gameObject instanceof GameObject) {
+ return [...gameObject.getTransform().scale];
+ }
+ throw new TypeError('Cannot query scale of non-GameObject');
+};
+
+/**
+ * Queries the [r, g, b, a] color property of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @returns [r, g, b, a] color as an array
+ * @example
+ * ```
+ * const gameobject = update_color(create_circle(100), [255, 127, 127, 255]);
+ * query_color(gameobject);
+ * ```
+ * @category GameObject
+ */
+export const query_color: (gameObject: RenderableGameObject) => ColorRGBA
+= (gameObject: RenderableGameObject) => {
+ if (gameObject instanceof RenderableGameObject) {
+ return [...gameObject.getColor()];
+ }
+ throw new TypeError('Cannot query color of non-GameObject');
+};
+
+/**
+ * Queries the [x, y] flip property of the GameObject.
+ *
+ * @param gameObject GameObject reference
+ * @returns [x, y] flip state as an array
+ * @example
+ * ```
+ * const gameobject = update_flip(create_triangle(100), [false, true]);
+ * query_flip(gameobject);
+ * ```
+ * @category GameObject
+ */
+export const query_flip: (gameObject: RenderableGameObject) => FlipXY
+= (gameObject: RenderableGameObject) => {
+ if (gameObject instanceof RenderableGameObject) {
+ return [...gameObject.getFlipState()];
+ }
+ throw new TypeError('Cannot query flip of non-GameObject');
+};
+
+/**
+ * Queries the text of a Text GameObject.
+ *
+ * @param textGameObject TextGameObject reference
+ * @returns text string associated with the Text GameObject
+ * @throws Error if not a TextGameObject is passed in
+ * @example
+ * ```
+ * const text = create_text("Hello World!");
+ * query_text(text);
+ * ```
+ * @category GameObject
+ */
+export const query_text: (textGameObject: TextGameObject) => string
+= (textGameObject: TextGameObject) => {
+ if (textGameObject instanceof TextGameObject) {
+ return textGameObject.getText().text;
+ }
+ throw new TypeError('Cannot query text of non-TextGameObject');
+};
+
+/**
+ * Queries the (mouse) pointer position.
+ *
+ * @returns [x, y] coordinates of the pointer as an array
+ * @example
+ * ```
+ * const position = query_pointer_position();
+ * position[0]; // x
+ * position[1]; // y
+ * ```
+ */
+export const query_pointer_position: () => PositionXY
+= () => gameState.pointerProps.pointerPosition;
+
+// =============================================================================
+// Game configuration
+// =============================================================================
+
+/**
+ * Private function to set the allowed range for a value.
+ *
+ * @param num the numeric value
+ * @param min the minimum value allowed for that number
+ * @param max the maximum value allowed for that number
+ * @returns a number within the interval
+ * @hidden
+ */
+const withinRange: (num: number, min: number, max: number) => number
+= (num: number, min: number, max: number) => {
+ if (num > max) {
+ return max;
+ }
+ if (num < min) {
+ return min;
+ }
+ return num;
+};
+
+/**
+ * Sets the frames per second of the canvas, which should be between the MIN_FPS and MAX_FPS.
+ * It ranges between 1 and 120, with the default target as 30.
+ * This function should not be called in the update function.
+ *
+ * @param fps The frames per second of canvas to set.
+ * @example
+ * ```
+ * // set fps to 60
+ * set_fps(60);
+ * ```
+ */
+export const set_fps: (fps: number) => void = (fps: number) => {
+ config.fps = withinRange(fps, MIN_FPS, MAX_FPS);
+};
+
+/**
+ * Sets the dimensions of the canvas, which should be between the
+ * min and max widths and height.
+ *
+ * @param dimensions An array containing [width, height] of the canvas.
+ * @example
+ * ```
+ * // set the width as 500 and height as 400
+ * set_dimensions([500, 400]);
+ * ```
+ */
+export const set_dimensions: (dimensions: DimensionsXY) => void = (dimensions: DimensionsXY) => {
+ if (dimensions.length !== 2) {
+ throw new Error('dimensions must be a 2-element array');
+ }
+ config.width = withinRange(dimensions[0], MIN_WIDTH, MAX_WIDTH);
+ config.height = withinRange(dimensions[1], MIN_HEIGHT, MAX_HEIGHT);
+};
+
+/**
+ * Sets the scale (zoom) of the pixels in the canvas.
+ * If scale is doubled, then the number of units across would be halved.
+ * This has a side effect of making the game pixelated if scale > 1.
+ * The default scale is 1.
+ *
+ * @param scale The scale of the canvas to set.
+ * @example
+ * ```
+ * // sets the scale of the canvas to 2.
+ * set_scale(2);
+ * ```
+ */
+export const set_scale: (scale: number) => void = (scale: number) => {
+ config.scale = withinRange(scale, MIN_SCALE, MAX_SCALE);
+};
+
+/**
+ * Enables debug mode.
+ * Hit box interaction between pointer and GameObjects are shown with a green outline in debug mode.
+ * Hit box interaction between GameObjects is based off a rectangular area instead, which is not reflected.
+ * debug_log(...) information is shown on the top-left corner of the canvas.
+ *
+ * @example
+ * ```
+ * enable_debug();
+ * update_loop(game_state => {
+ * debug_log(get_game_time());
+ * });
+ * ```
+ */
+export const enable_debug: () => void = () => {
+ config.isDebugEnabled = true;
+};
+
+
+/**
+ * Logs any information passed into it within the `update_loop`.
+ * Displays the information in the top-left corner of the canvas only if debug mode is enabled.
+ * Calling `display` within the `update_loop` function will not work as intended, so use `debug_log` instead.
+ *
+ * @param info The information to log.
+ * @example
+ * ```
+ * enable_debug();
+ * update_loop(game_state => {
+ * debug_log(get_game_time());
+ * });
+ * ```
+ */
+export const debug_log: (info: string) => void = (info: string) => {
+ if (config.isDebugEnabled) {
+ gameState.debugLogArray.push(info);
+ }
+};
+
+// =============================================================================
+// Game loop
+// =============================================================================
+
+/**
+ * Detects if a key input is pressed down.
+ * This function must be called in your update function to detect inputs.
+ * To get specific keys, go to https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key#result.
+ *
+ * @param key_name The key name of the key.
+ * @returns True, in the frame the key is pressed down.
+ * @example
+ * ```
+ * if (input_key_down("a")) {
+ * // "a" key is pressed down
+ * }
+ * ```
+ * @category Logic
+ */
+export const input_key_down: (key_name: string) => boolean = (key_name: string) => gameState.inputKeysDown.has(key_name);
+
+/**
+ * Detects if the left mouse button is pressed down.
+ * This function should be called in your update function.
+ *
+ * @returns True, if the left mouse button is pressed down.
+ * @example
+ * ```
+ * if(input_left_mouse_down()) {
+ * // Left mouse button down
+ * }
+ * ```
+ * @category Logic
+ */
+export const input_left_mouse_down: () => boolean = () => gameState.pointerProps.isPointerPrimaryDown;
+
+/**
+ * Detects if the right mouse button is pressed down.
+ * This function should be called in your update function.
+ *
+ * @returns True, if the right mouse button is pressed down.
+ * @example
+ * ```
+ * if (input_right_mouse_down()) {
+ * // Right mouse button down
+ * }
+ * ```
+ * @category Logic
+ */
+export const input_right_mouse_down: () => boolean = () => gameState.pointerProps.isPointerSecondaryDown;
+
+/**
+ * Detects if the (mouse) pointer is over the gameobject.
+ * This function should be called in your update function.
+ *
+ * @param gameObject The gameobject reference.
+ * @returns True, if the pointer is over the gameobject.
+ * @example
+ * ```
+ * // Creating a button using a gameobject
+ * const button = createTextGameObject("click me");
+ * // Test if button is clicked
+ * if (pointer_over_gameobject(button) && input_left_mouse_down()) {
+ * // Button is clicked
+ * }
+ * ```
+ * @category Logic
+ */
+export const pointer_over_gameobject = (gameObject: GameObject) => {
+ if (gameObject instanceof GameObject) {
+ return gameState.pointerProps.pointerOverGameObjectsId.has(gameObject.id);
+ }
+ throw new TypeError('Cannot check pointer over non-GameObject');
+};
+/**
+ * Checks if two gameobjects overlap with each other, using a rectangular bounding box.
+ * This bounding box is rectangular, for all GameObjects.
+ * This function should be called in your update function.
+ *
+ * @param gameObject1 The first gameobject reference.
+ * @param gameObject2 The second gameobject reference.
+ * @returns True, if both gameobjects overlap with each other.
+ * @example
+ * ```
+ * const rectangle1 = create_rectangle(100, 100);
+ * const rectangle2 = create_rectangle(100, 100);
+ * if (gameobjects_overlap(rectangle1, rectangle2)) {
+ * // Both rectangles overlap
+ * }
+ * ```
+ * @category Logic
+ */
+export const gameobjects_overlap: (gameObject1: InteractableGameObject, gameObject2: InteractableGameObject) => boolean
+= (gameObject1: InteractableGameObject, gameObject2: InteractableGameObject) => {
+ if (gameObject1 instanceof InteractableGameObject && gameObject2 instanceof InteractableGameObject) {
+ return gameObject1.isOverlapping(gameObject2);
+ }
+ throw new TypeError('Cannot check overlap of non-GameObject');
+};
+/**
+ * Gets the current in-game time, which is based off the start time.
+ * This function should be called in your update function.
+ *
+ * @returns a number specifying the time in milliseconds
+ * @example
+ * ```
+ * if (get_game_time() > 100) {
+ * // Do something after 100 milliseconds
+ * }
+ * ```
+ */
+export const get_game_time: () => number = () => gameState.gameTime;
+
+/**
+ * Gets the current loop count, which is the number of frames that have run.
+ * Depends on the framerate set for how fast this changes.
+ * This function should be called in your update function.
+ *
+ * @returns a number specifying number of loops that have been run.
+ * @example
+ * ```
+ * if (get_loop_count() === 100) {
+ * // Do something on the 100th frame
+ * }
+ * ```
+ */
+export const get_loop_count: () => number = () => gameState.loopCount;
+
+/**
+ * This sets the update loop in the canvas.
+ * The update loop is run once per frame, so it depends on the fps set for the number of times this loop is run.
+ * There should only be one update_loop called.
+ * All game logic should be handled within your update_function.
+ * You cannot create GameObjects inside the update_loop.
+ * game_state is an array that can be modified to store anything.
+ *
+ * @param update_function A user-defined update_function, that takes in an array as a parameter.
+ * @example
+ * ```
+ * // Create gameobjects outside update_loop
+ * update_loop((game_state) => {
+ * // Update gameobjects inside update_loop
+ *
+ * // Using game_state as a counter
+ * if (game_state[0] === undefined) {
+ * game_state[0] = 0;
+ * }
+ * game_state[0] = game_state[0] + 1;
+ * })
+ * ```
+ */
+export const update_loop: (update_function: UpdateFunction) => void = (update_function: UpdateFunction) => {
+ // Test for error in user update function
+ // This cannot not check for errors inside a block that is not run.
+ update_function([]);
+ config.userUpdateFunction = update_function;
+};
+
+/**
+ * Builds the game.
+ * Processes the initialization and updating of the game.
+ * All created GameObjects and their properties are passed into the game.
+ *
+ * @example
+ * ```
+ * // This must be the last function called in the Source program.
+ * build_game();
+ * ```
+ */
+export const build_game: () => BuildGame = () => {
+ // Reset frame and time counters.
+ gameState.loopCount = 0;
+ gameState.gameTime = 0;
+
+ const inputConfig = {
+ keyboard: true,
+ mouse: true,
+ windowEvents: false,
+ };
+
+ const fpsConfig = {
+ min: MIN_FPS,
+ target: config.fps,
+ forceSetTimeOut: true,
+ };
+
+ const gameConfig = {
+ width: config.width / config.scale,
+ height: config.height / config.scale,
+ zoom: config.scale,
+ // Setting to Phaser.WEBGL can lead to WebGL: INVALID_OPERATION errors, so Phaser.CANVAS is used instead.
+ // Also: Phaser.WEBGL can crash when there are too many contexts
+ // WEBGL is generally more performant, and allows for tinting of gameobjects.
+ type: Phaser.WEBGL,
+ parent: 'phaser-game',
+ scene: PhaserScene,
+ input: inputConfig,
+ fps: fpsConfig,
+ banner: false,
+ };
+
+ return {
+ toReplString: () => '[Arcade 2D]',
+ gameConfig,
+ };
+};
+
+// =============================================================================
+// Audio functions
+// =============================================================================
+
+/**
+ * Create an audio clip that can be referenced.
+ * Source Academy assets can be found at https://source-academy-assets.s3-ap-southeast-1.amazonaws.com/ with Ctrl+f ".mp3".
+ * Phaser audio assets can be found at https://labs.phaser.io/assets/audio.
+ * Phaser sound effects assets can be found at https://labs.phaser.io/assets/audio/SoundEffects/.
+ * If Phaser assets are unavailable, go to https://github.com/photonstorm/phaser3-examples/tree/master/public/assets
+ * to get the asset path and append it to `https://labs.phaser.io/assets/`.
+ * This function should not be called in your update function.
+ *
+ * @param audio_url The URL of the audio clip.
+ * @param volume_level A number between 0 to 1, representing the volume level of the audio clip.
+ * @returns The AudioClip reference
+ * @example
+ * ```
+ * const audioClip = create_audio("bgm/GalacticHarmony.mp3", 0.5);
+ * ```
+ * @category Audio
+ */
+export const create_audio: (audio_url: string, volume_level: number) => AudioClip
+= (audio_url: string, volume_level: number) => {
+ if (typeof audio_url !== 'string') {
+ throw new Error('audio_url must be a string');
+ }
+ if (typeof volume_level !== 'number') {
+ throw new Error('volume_level must be a number');
+ }
+ return AudioClip.of(audio_url, withinRange(volume_level, MIN_VOLUME, MAX_VOLUME));
+};
+
+/**
+ * Loops the audio clip provided, which will play the audio clip indefinitely.
+ * Setting whether an audio clip should loop be done outside the update function.
+ *
+ * @param audio_clip The AudioClip reference
+ * @returns The AudioClip reference
+ * @example
+ * ```
+ * const audioClip = loop_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
+ * ```
+ * @category Audio
+ */
+export const loop_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
+ if (audio_clip instanceof AudioClip) {
+ audio_clip.setShouldAudioClipLoop(true);
+ return audio_clip;
+ }
+ throw new TypeError('Cannot loop a non-AudioClip');
+};
+
+/**
+ * Plays the audio clip, and stops when the audio clip is over.
+ *
+ * @param audio_clip The AudioClip reference
+ * @returns The AudioClip reference
+ * @example
+ * ```
+ * const audioClip = play_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
+ * ```
+ * @category Audio
+ */
+export const play_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
+ if (audio_clip instanceof AudioClip) {
+ audio_clip.setShouldAudioClipPlay(true);
+ return audio_clip;
+ }
+ throw new TypeError('Cannot play a non-AudioClip');
+};
+
+/**
+ * Stops the audio clip immediately.
+ *
+ * @param audio_clip The AudioClip reference
+ * @returns The AudioClip reference
+ * @example
+ * ```
+ * const audioClip = play_audio(create_audio("bgm/GalacticHarmony.mp3", 0.5));
+ * ```
+ * @category Audio
+ */
+export const stop_audio: (audio_clip: AudioClip) => AudioClip = (audio_clip: AudioClip) => {
+ if (audio_clip instanceof AudioClip) {
+ audio_clip.setShouldAudioClipPlay(false);
+ return audio_clip;
+ }
+ throw new TypeError('Cannot stop a non-AudioClip');
+};
diff --git a/src/bundles/arcade_2d/gameobject.ts b/src/bundles/arcade_2d/gameobject.ts
index 9e45b6557..0b3126337 100644
--- a/src/bundles/arcade_2d/gameobject.ts
+++ b/src/bundles/arcade_2d/gameobject.ts
@@ -1,368 +1,368 @@
-/**
- * This file contains the bundle's representation of GameObjects.
- */
-import type { ReplResult } from '../../typings/type_helpers';
-import { DEFAULT_INTERACTABLE_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_TRANSFORM_PROPS } from './constants';
-import type * as types from './types';
-
-// =============================================================================
-// Classes
-// =============================================================================
-
-/**
- * Encapsulates the basic data-representation of a GameObject.
- */
-export abstract class GameObject implements Transformable, ReplResult {
- private static gameObjectCount: number = 0;
- protected static gameObjectsArray: InteractableGameObject[] = []; // Stores all the created GameObjects
- protected isTransformUpdated: boolean = false;
- public readonly id: number;
-
- constructor(
- private transformProps: types.TransformProps = DEFAULT_TRANSFORM_PROPS,
- ) {
- this.id = GameObject.gameObjectCount++;
- }
- setTransform(transformProps: types.TransformProps) {
- this.transformProps = transformProps;
- this.isTransformUpdated = false;
- }
- getTransform(): types.TransformProps {
- return this.transformProps;
- }
- hasTransformUpdates(): boolean {
- return !this.isTransformUpdated;
- }
- setTransformUpdated() {
- this.isTransformUpdated = true;
- }
-
- /**
- * This method is called when init() Phaser Scene.
- * @returns The GameObjects as an array.
- */
- public static getGameObjectsArray() {
- return GameObject.gameObjectsArray;
- }
-
- public toReplString = () => '';
-}
-
-/**
- * Encapsulates the basic data-representation of a RenderableGameObject.
- */
-export abstract class RenderableGameObject extends GameObject implements Renderable {
- protected isRenderUpdated: boolean = false;
- private shouldBringToTop: boolean = false;
-
- constructor(
- transformProps: types.TransformProps,
- private renderProps: types.RenderProps = DEFAULT_RENDER_PROPS,
- ) {
- super(transformProps);
- }
-
- setRenderState(renderProps: types.RenderProps) {
- // if (renderProps.renderedImage === undefined || this.phaserType === undefined) {
- // throw new Error('GameObject\'s render image type is undefined');
- // }
- // if (this.phaserType in renderProps.renderedImage) {
- // throw new Error('Unable to update GameObject with image type that does not match');
- // }
- this.renderProps = renderProps;
- this.isRenderUpdated = false;
- }
- getRenderState(): types.RenderProps {
- return this.renderProps;
- }
- /**
- * Sets a flag to render the GameObject infront of other GameObjects.
- */
- setBringToTopFlag() {
- this.shouldBringToTop = true;
- this.isRenderUpdated = false;
- }
- hasRenderUpdates(): boolean {
- return !this.isRenderUpdated;
- }
- setRenderUpdated() {
- this.isRenderUpdated = true;
- this.shouldBringToTop = false;
- }
- getColor(): types.ColorRGBA {
- return this.renderProps.color;
- }
- getFlipState(): types.FlipXY {
- return this.renderProps.flip;
- }
- getShouldBringToTop(): boolean {
- return this.shouldBringToTop;
- }
-}
-
-/**
- * Encapsulates the basic data-representation of a InteractableGameObject.
- */
-export abstract class InteractableGameObject extends RenderableGameObject implements Interactable {
- protected isHitboxUpdated: boolean = false;
- protected phaserGameObject: types.PhaserGameObject | undefined;
-
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- private interactableProps: types.InteractableProps = DEFAULT_INTERACTABLE_PROPS,
- ) {
- super(transformProps, renderProps);
- GameObject.gameObjectsArray.push(this);
- }
- setHitboxState(hitboxProps: types.InteractableProps) {
- this.interactableProps = hitboxProps;
- this.isHitboxUpdated = false;
- }
- getHitboxState(): types.InteractableProps {
- return this.interactableProps;
- }
- hasHitboxUpdates(): boolean {
- return !this.isHitboxUpdated;
- }
- setHitboxUpdated() {
- this.isHitboxUpdated = true;
- }
- /**
- * This stores the GameObject within the phaser game, which can only be set after the game has started.
- * @param phaserGameObject The phaser GameObject reference.
- */
- setPhaserGameObject(phaserGameObject: Phaser.GameObjects.Shape | Phaser.GameObjects.Sprite | Phaser.GameObjects.Text) {
- this.phaserGameObject = phaserGameObject;
- }
- /**
- * Checks if two Source GameObjects are overlapping, using their Phaser GameObjects to check.
- * This method needs to be overriden if the hit area of the Phaser GameObject is not a rectangle.
- * @param other The other GameObject
- * @returns True, if both GameObjects overlap in the phaser game.
- */
- isOverlapping(other: InteractableGameObject): boolean {
- if (this.phaserGameObject === undefined || other.phaserGameObject === undefined) {
- return false;
- }
- // Use getBounds to check if two objects overlap, checking the shape of the area before checking overlap.
- // Since getBounds() returns a Rectangle, it will be unable to check the actual intersection of non-rectangular shapes.
- // eslint-disable-next-line new-cap
- return Phaser.Geom.Intersects.RectangleToRectangle(this.phaserGameObject.getBounds(), other.phaserGameObject.getBounds());
- }
-}
-
-/**
- * Encapsulates the data-representation of a ShapeGameObject.
- */
-export abstract class ShapeGameObject extends InteractableGameObject {
- /**
- * Gets the shape properties of the ShapeGameObject.
- * @returns The shape properties.
- */
- public abstract getShape();
-
- /** @override */
- public toReplString = () => '';
-
- /** @override */
- public toString = () => this.toReplString();
-}
-
-/**
- * Encapsulates the data-representation of a RectangleGameObject.
- */
-export class RectangleGameObject extends ShapeGameObject {
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- interactableProps: types.InteractableProps,
- private rectangle: types.RectangleProps,
- ) {
- super(transformProps, renderProps, interactableProps);
- }
- /** @override */
- getShape(): types.RectangleProps {
- return this.rectangle;
- }
-}
-
-/**
- * Encapsulates the data-representation of a CircleGameObject.
- */
-export class CircleGameObject extends ShapeGameObject {
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- interactableProps: types.InteractableProps,
- private circle: types.CircleProps,
- ) {
- super(transformProps, renderProps, interactableProps);
- }
- /** @override */
- getShape(): types.CircleProps {
- return this.circle;
- }
-}
-
-/**
- * Encapsulates the data-representation of a TriangleGameObject.
- */
-export class TriangleGameObject extends ShapeGameObject {
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- interactableProps: types.InteractableProps,
- private triangle: types.TriangleProps,
- ) {
- super(transformProps, renderProps, interactableProps);
- }
- /** @override */
- getShape(): types.TriangleProps {
- return this.triangle;
- }
-}
-
-/**
- * Encapsulates the data-representation of a SpriteGameObject.
- */
-export class SpriteGameObject extends InteractableGameObject {
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- interactableProps: types.InteractableProps,
- private sprite: types.Sprite,
- ) {
- super(transformProps, renderProps, interactableProps);
- }
- /**
- * Gets the sprite displayed by the SpriteGameObject.
- * @returns The sprite as a Sprite.
- */
- getSprite(): types.Sprite {
- return this.sprite;
- }
-
- /** @override */
- public toReplString = () => '';
-
- /** @override */
- public toString = () => this.toReplString();
-}
-
-/**
- * Encapsulates the data-representation of a TextGameObject.
- */
-export class TextGameObject extends InteractableGameObject {
- constructor(
- transformProps: types.TransformProps,
- renderProps: types.RenderProps,
- interactableProps: types.InteractableProps,
- private displayText: types.DisplayText,
- ) {
- super(transformProps, renderProps, interactableProps);
- }
- /**
- * Sets the text displayed by the GameObject in the canvas.
- * @param text The text displayed.
- */
- setText(text: types.DisplayText) {
- this.setRenderState(this.getRenderState());
- this.displayText = text;
- }
- /**
- * Gets the text displayed by the GameObject in the canvas.
- * @returns The text displayed.
- */
- getText(): types.DisplayText {
- return this.displayText;
- }
-
- /** @override */
- public toReplString = () => '';
-
- /** @override */
- public toString = () => this.toReplString();
-}
-
-// =============================================================================
-// Interfaces
-// =============================================================================
-
-/**
- * Interface to represent GameObjects that can undergo transformations.
- */
-interface Transformable {
- /**
- * @param renderProps The transform properties of the GameObject.
- */
- setTransform(transformProps: types.TransformProps);
-
- /**
- * @returns The render properties of the GameObject.
- */
- getTransform(): types.TransformProps;
-
- /**
- * Checks if the transform properties needs to update
- * @returns Determines if transform of the GameObject in the canvas needs to be updated.
- */
- hasTransformUpdates(): boolean;
-
- /**
- * Should be called when the GameObject's transform has been updated in the canvas.
- */
- setTransformUpdated();
-}
-
-/**
- * Interface to represent GameObjects that can be rendered in the canvas.
- */
-interface Renderable {
- /**
- * @param renderProps The render properties of the GameObject.
- */
- setRenderState(renderProps: types.RenderProps);
-
- /**
- * @returns The render properties of the GameObject.
- */
- getRenderState(): types.RenderProps;
-
- /**
- * Checks if the render properties needs to update.
- * @returns Determines if rendered image of the GameObject in the canvas needs to be updated.
- */
- hasRenderUpdates(): boolean;
-
- /**
- * Should be called when the GameObject's rendered image has been updated in the canvas.
- */
- setRenderUpdated();
-}
-
-/**
- * Interface to represent GameObjects that can be interacted with.
- */
-interface Interactable {
- /**
- * @param active The hitbox state of the GameObject in detecting overlaps.
- */
- setHitboxState(interactableProps: types.InteractableProps);
-
- /**
- * @returns The hitbox state of the GameObject in detecting overlaps.
- */
- getHitboxState(): types.InteractableProps;
-
- /**
- * Checks if the interactivity properties needs to update
- * @returns Determines if hitbox of the GameObject in the canvas needs to be updated.
- */
- hasHitboxUpdates(): boolean;
-
- /**
- * Should be called when the GameObject's hitbox has been updated in the canvas.
- */
- setHitboxUpdated();
-}
+/**
+ * This file contains the bundle's representation of GameObjects.
+ */
+import type { ReplResult } from '../../typings/type_helpers';
+import { DEFAULT_INTERACTABLE_PROPS, DEFAULT_RENDER_PROPS, DEFAULT_TRANSFORM_PROPS } from './constants';
+import type * as types from './types';
+
+// =============================================================================
+// Classes
+// =============================================================================
+
+/**
+ * Encapsulates the basic data-representation of a GameObject.
+ */
+export abstract class GameObject implements Transformable, ReplResult {
+ private static gameObjectCount: number = 0;
+ protected static gameObjectsArray: InteractableGameObject[] = []; // Stores all the created GameObjects
+ protected isTransformUpdated: boolean = false;
+ public readonly id: number;
+
+ constructor(
+ private transformProps: types.TransformProps = DEFAULT_TRANSFORM_PROPS,
+ ) {
+ this.id = GameObject.gameObjectCount++;
+ }
+ setTransform(transformProps: types.TransformProps) {
+ this.transformProps = transformProps;
+ this.isTransformUpdated = false;
+ }
+ getTransform(): types.TransformProps {
+ return this.transformProps;
+ }
+ hasTransformUpdates(): boolean {
+ return !this.isTransformUpdated;
+ }
+ setTransformUpdated() {
+ this.isTransformUpdated = true;
+ }
+
+ /**
+ * This method is called when init() Phaser Scene.
+ * @returns The GameObjects as an array.
+ */
+ public static getGameObjectsArray() {
+ return GameObject.gameObjectsArray;
+ }
+
+ public toReplString = () => '';
+}
+
+/**
+ * Encapsulates the basic data-representation of a RenderableGameObject.
+ */
+export abstract class RenderableGameObject extends GameObject implements Renderable {
+ protected isRenderUpdated: boolean = false;
+ private shouldBringToTop: boolean = false;
+
+ constructor(
+ transformProps: types.TransformProps,
+ private renderProps: types.RenderProps = DEFAULT_RENDER_PROPS,
+ ) {
+ super(transformProps);
+ }
+
+ setRenderState(renderProps: types.RenderProps) {
+ // if (renderProps.renderedImage === undefined || this.phaserType === undefined) {
+ // throw new Error('GameObject\'s render image type is undefined');
+ // }
+ // if (this.phaserType in renderProps.renderedImage) {
+ // throw new Error('Unable to update GameObject with image type that does not match');
+ // }
+ this.renderProps = renderProps;
+ this.isRenderUpdated = false;
+ }
+ getRenderState(): types.RenderProps {
+ return this.renderProps;
+ }
+ /**
+ * Sets a flag to render the GameObject infront of other GameObjects.
+ */
+ setBringToTopFlag() {
+ this.shouldBringToTop = true;
+ this.isRenderUpdated = false;
+ }
+ hasRenderUpdates(): boolean {
+ return !this.isRenderUpdated;
+ }
+ setRenderUpdated() {
+ this.isRenderUpdated = true;
+ this.shouldBringToTop = false;
+ }
+ getColor(): types.ColorRGBA {
+ return this.renderProps.color;
+ }
+ getFlipState(): types.FlipXY {
+ return this.renderProps.flip;
+ }
+ getShouldBringToTop(): boolean {
+ return this.shouldBringToTop;
+ }
+}
+
+/**
+ * Encapsulates the basic data-representation of a InteractableGameObject.
+ */
+export abstract class InteractableGameObject extends RenderableGameObject implements Interactable {
+ protected isHitboxUpdated: boolean = false;
+ protected phaserGameObject: types.PhaserGameObject | undefined;
+
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ private interactableProps: types.InteractableProps = DEFAULT_INTERACTABLE_PROPS,
+ ) {
+ super(transformProps, renderProps);
+ GameObject.gameObjectsArray.push(this);
+ }
+ setHitboxState(hitboxProps: types.InteractableProps) {
+ this.interactableProps = hitboxProps;
+ this.isHitboxUpdated = false;
+ }
+ getHitboxState(): types.InteractableProps {
+ return this.interactableProps;
+ }
+ hasHitboxUpdates(): boolean {
+ return !this.isHitboxUpdated;
+ }
+ setHitboxUpdated() {
+ this.isHitboxUpdated = true;
+ }
+ /**
+ * This stores the GameObject within the phaser game, which can only be set after the game has started.
+ * @param phaserGameObject The phaser GameObject reference.
+ */
+ setPhaserGameObject(phaserGameObject: Phaser.GameObjects.Shape | Phaser.GameObjects.Sprite | Phaser.GameObjects.Text) {
+ this.phaserGameObject = phaserGameObject;
+ }
+ /**
+ * Checks if two Source GameObjects are overlapping, using their Phaser GameObjects to check.
+ * This method needs to be overriden if the hit area of the Phaser GameObject is not a rectangle.
+ * @param other The other GameObject
+ * @returns True, if both GameObjects overlap in the phaser game.
+ */
+ isOverlapping(other: InteractableGameObject): boolean {
+ if (this.phaserGameObject === undefined || other.phaserGameObject === undefined) {
+ return false;
+ }
+ // Use getBounds to check if two objects overlap, checking the shape of the area before checking overlap.
+ // Since getBounds() returns a Rectangle, it will be unable to check the actual intersection of non-rectangular shapes.
+ // eslint-disable-next-line new-cap
+ return Phaser.Geom.Intersects.RectangleToRectangle(this.phaserGameObject.getBounds(), other.phaserGameObject.getBounds());
+ }
+}
+
+/**
+ * Encapsulates the data-representation of a ShapeGameObject.
+ */
+export abstract class ShapeGameObject extends InteractableGameObject {
+ /**
+ * Gets the shape properties of the ShapeGameObject.
+ * @returns The shape properties.
+ */
+ public abstract getShape();
+
+ /** @override */
+ public toReplString = () => '';
+
+ /** @override */
+ public toString = () => this.toReplString();
+}
+
+/**
+ * Encapsulates the data-representation of a RectangleGameObject.
+ */
+export class RectangleGameObject extends ShapeGameObject {
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ interactableProps: types.InteractableProps,
+ private rectangle: types.RectangleProps,
+ ) {
+ super(transformProps, renderProps, interactableProps);
+ }
+ /** @override */
+ getShape(): types.RectangleProps {
+ return this.rectangle;
+ }
+}
+
+/**
+ * Encapsulates the data-representation of a CircleGameObject.
+ */
+export class CircleGameObject extends ShapeGameObject {
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ interactableProps: types.InteractableProps,
+ private circle: types.CircleProps,
+ ) {
+ super(transformProps, renderProps, interactableProps);
+ }
+ /** @override */
+ getShape(): types.CircleProps {
+ return this.circle;
+ }
+}
+
+/**
+ * Encapsulates the data-representation of a TriangleGameObject.
+ */
+export class TriangleGameObject extends ShapeGameObject {
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ interactableProps: types.InteractableProps,
+ private triangle: types.TriangleProps,
+ ) {
+ super(transformProps, renderProps, interactableProps);
+ }
+ /** @override */
+ getShape(): types.TriangleProps {
+ return this.triangle;
+ }
+}
+
+/**
+ * Encapsulates the data-representation of a SpriteGameObject.
+ */
+export class SpriteGameObject extends InteractableGameObject {
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ interactableProps: types.InteractableProps,
+ private sprite: types.Sprite,
+ ) {
+ super(transformProps, renderProps, interactableProps);
+ }
+ /**
+ * Gets the sprite displayed by the SpriteGameObject.
+ * @returns The sprite as a Sprite.
+ */
+ getSprite(): types.Sprite {
+ return this.sprite;
+ }
+
+ /** @override */
+ public toReplString = () => '';
+
+ /** @override */
+ public toString = () => this.toReplString();
+}
+
+/**
+ * Encapsulates the data-representation of a TextGameObject.
+ */
+export class TextGameObject extends InteractableGameObject {
+ constructor(
+ transformProps: types.TransformProps,
+ renderProps: types.RenderProps,
+ interactableProps: types.InteractableProps,
+ private displayText: types.DisplayText,
+ ) {
+ super(transformProps, renderProps, interactableProps);
+ }
+ /**
+ * Sets the text displayed by the GameObject in the canvas.
+ * @param text The text displayed.
+ */
+ setText(text: types.DisplayText) {
+ this.setRenderState(this.getRenderState());
+ this.displayText = text;
+ }
+ /**
+ * Gets the text displayed by the GameObject in the canvas.
+ * @returns The text displayed.
+ */
+ getText(): types.DisplayText {
+ return this.displayText;
+ }
+
+ /** @override */
+ public toReplString = () => '';
+
+ /** @override */
+ public toString = () => this.toReplString();
+}
+
+// =============================================================================
+// Interfaces
+// =============================================================================
+
+/**
+ * Interface to represent GameObjects that can undergo transformations.
+ */
+interface Transformable {
+ /**
+ * @param renderProps The transform properties of the GameObject.
+ */
+ setTransform(transformProps: types.TransformProps);
+
+ /**
+ * @returns The render properties of the GameObject.
+ */
+ getTransform(): types.TransformProps;
+
+ /**
+ * Checks if the transform properties needs to update
+ * @returns Determines if transform of the GameObject in the canvas needs to be updated.
+ */
+ hasTransformUpdates(): boolean;
+
+ /**
+ * Should be called when the GameObject's transform has been updated in the canvas.
+ */
+ setTransformUpdated();
+}
+
+/**
+ * Interface to represent GameObjects that can be rendered in the canvas.
+ */
+interface Renderable {
+ /**
+ * @param renderProps The render properties of the GameObject.
+ */
+ setRenderState(renderProps: types.RenderProps);
+
+ /**
+ * @returns The render properties of the GameObject.
+ */
+ getRenderState(): types.RenderProps;
+
+ /**
+ * Checks if the render properties needs to update.
+ * @returns Determines if rendered image of the GameObject in the canvas needs to be updated.
+ */
+ hasRenderUpdates(): boolean;
+
+ /**
+ * Should be called when the GameObject's rendered image has been updated in the canvas.
+ */
+ setRenderUpdated();
+}
+
+/**
+ * Interface to represent GameObjects that can be interacted with.
+ */
+interface Interactable {
+ /**
+ * @param active The hitbox state of the GameObject in detecting overlaps.
+ */
+ setHitboxState(interactableProps: types.InteractableProps);
+
+ /**
+ * @returns The hitbox state of the GameObject in detecting overlaps.
+ */
+ getHitboxState(): types.InteractableProps;
+
+ /**
+ * Checks if the interactivity properties needs to update
+ * @returns Determines if hitbox of the GameObject in the canvas needs to be updated.
+ */
+ hasHitboxUpdates(): boolean;
+
+ /**
+ * Should be called when the GameObject's hitbox has been updated in the canvas.
+ */
+ setHitboxUpdated();
+}
diff --git a/src/bundles/arcade_2d/index.ts b/src/bundles/arcade_2d/index.ts
index dc49ea10b..90234e307 100644
--- a/src/bundles/arcade_2d/index.ts
+++ b/src/bundles/arcade_2d/index.ts
@@ -1,259 +1,259 @@
-/**
- * Arcade2D allows users to create their own 2D games with Source §3 and above variants.
- * This module will simplify the features available in Phaser 3 to make it more accessible
- * to CS1101S students. Some examples have been included below.
- *
- * How to use Arcade2D:
- * 1. Create gameobjects: create gameobjects using any of the create functions, such as `create_rectangle`.
- * 2. Create update function: call `update_loop` with your update function as an argument.
- * Your update function should take in an array as an argument, which you can use for maintaining
- * your game state. The logic of your game is contained within your update function.
- * 3. Build the game: call `build_game` as the last statement.
- *
- * ### WASD input example
- * ```
-import { create_rectangle, query_position, update_position, update_loop, build_game, input_key_down } from "arcade_2d";
-
-// Create GameObjects outside update_loop(...)
-const player = update_position(create_rectangle(100, 100), [300, 300]);
-const movement_dist = 10;
-
-function add_vectors(to, from) {
- to[0] = to[0] + from[0];
- to[1] = to[1] + from[1];
-}
-
-update_loop(game_state => {
- const new_position = query_position(player);
-
- if (input_key_down("w")) {
- add_vectors(new_position, [0, -1 * movement_dist]);
- }
- if (input_key_down("a")) {
- add_vectors(new_position, [-1 * movement_dist, 0]);
- }
- if (input_key_down("s")) {
- add_vectors(new_position, [0, movement_dist]);
- }
- if (input_key_down("d")) {
- add_vectors(new_position, [movement_dist, 0]);
- }
-
- // Update GameObjects within update_loop(...)
- update_position(player, new_position);
-});
-build_game();
- * ```
- *
- * ### Draggable objects example
- * ```
-import { create_sprite, update_position, update_scale, pointer_over_gameobject, input_left_mouse_down, update_to_top, query_pointer_position, update_loop, build_game } from "arcade_2d";
-
-// Using assets
-const gameobjects = [
- update_position(create_sprite("objects/cmr/splendall.png"), [200, 400]),
- update_position(update_scale(create_sprite("avatars/beat/beat.happy.png"), [0.3, 0.3]), [300, 200]),
- update_position(update_scale(create_sprite("avatars/chieftain/chieftain.happy.png"), [0.2, 0.2]), [400, 300])];
-
-// Simple dragging function
-function drag_gameobject(gameobject) {
- if (input_left_mouse_down() && pointer_over_gameobject(gameobject)) {
- update_to_top(update_position(gameobject, query_pointer_position()));
- }
-}
-
-update_loop(game_state => {
- for (let i = 0; i < 3; i = i + 1) {
- drag_gameobject(gameobjects[i]);
- }
-});
-build_game();
- * ```
- *
- * ### Playing audio example
- * ```
-import { input_key_down, create_audio, play_audio, update_loop, build_game } from "arcade_2d";
-
-const audio = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/key.wav", 1);
-update_loop(game_state => {
- // Press space to play audio
- if (input_key_down(" ")) {
- play_audio(audio);
- }
-});
-build_game();
- * ```
- * ### Grid colouring example
- *
- * ```
-import { create_rectangle, update_position, update_color, get_loop_count, set_scale, update_loop, build_game } from "arcade_2d";
-
-const gameobjects = [];
-for (let i = 0; i < 100; i = i + 1) {
- gameobjects[i] = [];
- for (let j = 0; j < 100; j = j + 1) {
- gameobjects[i][j] = update_position(create_rectangle(1, 1), [i, j]);
- }
-}
-
-update_loop(game_state => {
- const k = get_loop_count();
- for (let i = 0; i < 100; i = i + 1) {
- for (let j = 0; j < 100; j = j + 1) {
- update_color(gameobjects[i][j],
- [k * i * j * 7, k * j * i * 2, i * j * k * 3, i * j * k * 5]);
- }
- }
-});
-set_scale(6);
-build_game();
- * ```
- * ### Snake game example
- * ```
-import { create_rectangle, create_sprite, create_text, query_position, update_color, update_position, update_scale, update_text, update_to_top, set_fps, get_loop_count, enable_debug, debug_log, input_key_down, gameobjects_overlap, update_loop, build_game, create_audio, loop_audio, stop_audio, play_audio } from "arcade_2d";
-// enable_debug(); // Uncomment this to see debug info
-
-// Constants
-let snake_length = 4;
-const food_growth = 4;
-set_fps(10);
-
-const snake = [];
-const size = 600;
-const unit = 30;
-const grid = size / unit;
-const start_length = snake_length;
-
-// Create Sprite Gameobjects
-update_scale(create_sprite("https://labs.phaser.io/assets/games/germs/background.png"), [4, 4]); // Background
-const food = create_sprite("https://labs.phaser.io/assets/sprites/tomato.png");
-let eaten = true;
-
-for (let i = 0; i < 1000; i = i + 1) {
- snake[i] = update_color(update_position(create_rectangle(unit, unit), [-unit / 2, -unit / 2]),
- [127 + 128 * math_sin(i / 20), 127 + 128 * math_sin(i / 50), 127 + 128 * math_sin(i / 30), 255]); // Store offscreen
-}
-const snake_head = update_color(update_position(create_rectangle(unit * 0.9, unit * 0.9), [-unit / 2, -unit / 2]), [0, 0, 0 ,0]); // Head
-
-let move_dir = [unit, 0];
-
-// Other functions
-const add_vec = (v1, v2) => [v1[0] + v2[0], v1[1] + v2[1]];
-const bound_vec = v => [(v[0] + size) % size, (v[1] + size) % size];
-function input() {
- if (input_key_down("w") && move_dir[1] === 0) {
- move_dir = [0, -unit];
- play_audio(move);
- } else if (input_key_down("a") && move_dir[0] === 0) {
- move_dir = [-unit, 0];
- play_audio(move);
- } else if (input_key_down("s") && move_dir[1] === 0) {
- move_dir = [0, unit];
- play_audio(move);
- } else if (input_key_down("d") && move_dir[0] === 0) {
- move_dir = [unit, 0];
- play_audio(move);
- }
-}
-let alive = true;
-
-// Create Text Gameobjects
-const score = update_position(create_text("Score: "), [size - 60, 20]);
-const game_text = update_color(update_scale(update_position(create_text(""), [size / 2, size / 2]), [2, 2]), [0, 0, 0, 255]);
-
-// Audio
-const eat = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/key.wav", 1);
-const lose = create_audio("https://labs.phaser.io/assets/audio/stacker/gamelost.m4a", 1);
-const move = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/alien_death1.wav", 1);
-const bg_audio = play_audio(loop_audio(create_audio("https://labs.phaser.io/assets/audio/tech/bass.mp3", 0.5)));
-
-// Create Update loop
-update_loop(game_state => {
- update_text(score, "Score: " + stringify(snake_length - start_length));
- if (!alive) {
- update_text(game_text, "Game Over!");
- return undefined;
- }
-
- // Move snake
- for (let i = snake_length - 1; i > 0; i = i - 1) {
- update_position(snake[i], query_position(snake[i - 1]));
- }
- update_position(snake[0], query_position(snake_head)); // Update head
- update_position(snake_head, bound_vec(add_vec(query_position(snake_head), move_dir))); // Update head
- debug_log(query_position(snake[0])); // Head
-
- input();
-
- // Add food
- if (eaten) {
- update_position(food, [math_floor(math_random() * grid) * unit + unit / 2, math_floor(math_random() * grid) * unit + unit / 2]);
- eaten = false;
- }
-
- // Eat food
- if (get_loop_count() > 1 && gameobjects_overlap(snake_head, food)) {
- eaten = true;
- snake_length = snake_length + food_growth;
- play_audio(eat);
- }
- debug_log(snake_length); // Score
-
- // Check collision
- if (get_loop_count() > start_length) {
- for (let i = 0; i < snake_length; i = i + 1) {
- if (gameobjects_overlap(snake_head, snake[i])) {
- alive = false;
- play_audio(lose);
- stop_audio(bg_audio);
- }
- }
- }
-});
-build_game();
- * ```
- * @module arcade_2d
- * @author Titus Chew Xuan Jun
- * @author Xenos Fiorenzo Anong
- */
-
-export {
- create_circle,
- create_rectangle,
- create_triangle,
- create_sprite,
- create_text,
- query_color,
- query_flip,
- query_id,
- query_pointer_position,
- query_position,
- query_rotation,
- query_scale,
- query_text,
- update_color,
- update_flip,
- update_position,
- update_rotation,
- update_scale,
- update_text,
- update_to_top,
- set_fps,
- set_dimensions,
- set_scale,
- get_game_time,
- get_loop_count,
- enable_debug,
- debug_log,
- input_key_down,
- input_left_mouse_down,
- input_right_mouse_down,
- pointer_over_gameobject,
- gameobjects_overlap,
- update_loop,
- build_game,
- create_audio,
- loop_audio,
- stop_audio,
- play_audio,
-} from './functions';
+/**
+ * Arcade2D allows users to create their own 2D games with Source §3 and above variants.
+ * This module will simplify the features available in Phaser 3 to make it more accessible
+ * to CS1101S students. Some examples have been included below.
+ *
+ * How to use Arcade2D:
+ * 1. Create gameobjects: create gameobjects using any of the create functions, such as `create_rectangle`.
+ * 2. Create update function: call `update_loop` with your update function as an argument.
+ * Your update function should take in an array as an argument, which you can use for maintaining
+ * your game state. The logic of your game is contained within your update function.
+ * 3. Build the game: call `build_game` as the last statement.
+ *
+ * ### WASD input example
+ * ```
+import { create_rectangle, query_position, update_position, update_loop, build_game, input_key_down } from "arcade_2d";
+
+// Create GameObjects outside update_loop(...)
+const player = update_position(create_rectangle(100, 100), [300, 300]);
+const movement_dist = 10;
+
+function add_vectors(to, from) {
+ to[0] = to[0] + from[0];
+ to[1] = to[1] + from[1];
+}
+
+update_loop(game_state => {
+ const new_position = query_position(player);
+
+ if (input_key_down("w")) {
+ add_vectors(new_position, [0, -1 * movement_dist]);
+ }
+ if (input_key_down("a")) {
+ add_vectors(new_position, [-1 * movement_dist, 0]);
+ }
+ if (input_key_down("s")) {
+ add_vectors(new_position, [0, movement_dist]);
+ }
+ if (input_key_down("d")) {
+ add_vectors(new_position, [movement_dist, 0]);
+ }
+
+ // Update GameObjects within update_loop(...)
+ update_position(player, new_position);
+});
+build_game();
+ * ```
+ *
+ * ### Draggable objects example
+ * ```
+import { create_sprite, update_position, update_scale, pointer_over_gameobject, input_left_mouse_down, update_to_top, query_pointer_position, update_loop, build_game } from "arcade_2d";
+
+// Using assets
+const gameobjects = [
+ update_position(create_sprite("objects/cmr/splendall.png"), [200, 400]),
+ update_position(update_scale(create_sprite("avatars/beat/beat.happy.png"), [0.3, 0.3]), [300, 200]),
+ update_position(update_scale(create_sprite("avatars/chieftain/chieftain.happy.png"), [0.2, 0.2]), [400, 300])];
+
+// Simple dragging function
+function drag_gameobject(gameobject) {
+ if (input_left_mouse_down() && pointer_over_gameobject(gameobject)) {
+ update_to_top(update_position(gameobject, query_pointer_position()));
+ }
+}
+
+update_loop(game_state => {
+ for (let i = 0; i < 3; i = i + 1) {
+ drag_gameobject(gameobjects[i]);
+ }
+});
+build_game();
+ * ```
+ *
+ * ### Playing audio example
+ * ```
+import { input_key_down, create_audio, play_audio, update_loop, build_game } from "arcade_2d";
+
+const audio = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/key.wav", 1);
+update_loop(game_state => {
+ // Press space to play audio
+ if (input_key_down(" ")) {
+ play_audio(audio);
+ }
+});
+build_game();
+ * ```
+ * ### Grid colouring example
+ *
+ * ```
+import { create_rectangle, update_position, update_color, get_loop_count, set_scale, update_loop, build_game } from "arcade_2d";
+
+const gameobjects = [];
+for (let i = 0; i < 100; i = i + 1) {
+ gameobjects[i] = [];
+ for (let j = 0; j < 100; j = j + 1) {
+ gameobjects[i][j] = update_position(create_rectangle(1, 1), [i, j]);
+ }
+}
+
+update_loop(game_state => {
+ const k = get_loop_count();
+ for (let i = 0; i < 100; i = i + 1) {
+ for (let j = 0; j < 100; j = j + 1) {
+ update_color(gameobjects[i][j],
+ [k * i * j * 7, k * j * i * 2, i * j * k * 3, i * j * k * 5]);
+ }
+ }
+});
+set_scale(6);
+build_game();
+ * ```
+ * ### Snake game example
+ * ```
+import { create_rectangle, create_sprite, create_text, query_position, update_color, update_position, update_scale, update_text, update_to_top, set_fps, get_loop_count, enable_debug, debug_log, input_key_down, gameobjects_overlap, update_loop, build_game, create_audio, loop_audio, stop_audio, play_audio } from "arcade_2d";
+// enable_debug(); // Uncomment this to see debug info
+
+// Constants
+let snake_length = 4;
+const food_growth = 4;
+set_fps(10);
+
+const snake = [];
+const size = 600;
+const unit = 30;
+const grid = size / unit;
+const start_length = snake_length;
+
+// Create Sprite Gameobjects
+update_scale(create_sprite("https://labs.phaser.io/assets/games/germs/background.png"), [4, 4]); // Background
+const food = create_sprite("https://labs.phaser.io/assets/sprites/tomato.png");
+let eaten = true;
+
+for (let i = 0; i < 1000; i = i + 1) {
+ snake[i] = update_color(update_position(create_rectangle(unit, unit), [-unit / 2, -unit / 2]),
+ [127 + 128 * math_sin(i / 20), 127 + 128 * math_sin(i / 50), 127 + 128 * math_sin(i / 30), 255]); // Store offscreen
+}
+const snake_head = update_color(update_position(create_rectangle(unit * 0.9, unit * 0.9), [-unit / 2, -unit / 2]), [0, 0, 0 ,0]); // Head
+
+let move_dir = [unit, 0];
+
+// Other functions
+const add_vec = (v1, v2) => [v1[0] + v2[0], v1[1] + v2[1]];
+const bound_vec = v => [(v[0] + size) % size, (v[1] + size) % size];
+function input() {
+ if (input_key_down("w") && move_dir[1] === 0) {
+ move_dir = [0, -unit];
+ play_audio(move);
+ } else if (input_key_down("a") && move_dir[0] === 0) {
+ move_dir = [-unit, 0];
+ play_audio(move);
+ } else if (input_key_down("s") && move_dir[1] === 0) {
+ move_dir = [0, unit];
+ play_audio(move);
+ } else if (input_key_down("d") && move_dir[0] === 0) {
+ move_dir = [unit, 0];
+ play_audio(move);
+ }
+}
+let alive = true;
+
+// Create Text Gameobjects
+const score = update_position(create_text("Score: "), [size - 60, 20]);
+const game_text = update_color(update_scale(update_position(create_text(""), [size / 2, size / 2]), [2, 2]), [0, 0, 0, 255]);
+
+// Audio
+const eat = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/key.wav", 1);
+const lose = create_audio("https://labs.phaser.io/assets/audio/stacker/gamelost.m4a", 1);
+const move = create_audio("https://labs.phaser.io/assets/audio/SoundEffects/alien_death1.wav", 1);
+const bg_audio = play_audio(loop_audio(create_audio("https://labs.phaser.io/assets/audio/tech/bass.mp3", 0.5)));
+
+// Create Update loop
+update_loop(game_state => {
+ update_text(score, "Score: " + stringify(snake_length - start_length));
+ if (!alive) {
+ update_text(game_text, "Game Over!");
+ return undefined;
+ }
+
+ // Move snake
+ for (let i = snake_length - 1; i > 0; i = i - 1) {
+ update_position(snake[i], query_position(snake[i - 1]));
+ }
+ update_position(snake[0], query_position(snake_head)); // Update head
+ update_position(snake_head, bound_vec(add_vec(query_position(snake_head), move_dir))); // Update head
+ debug_log(query_position(snake[0])); // Head
+
+ input();
+
+ // Add food
+ if (eaten) {
+ update_position(food, [math_floor(math_random() * grid) * unit + unit / 2, math_floor(math_random() * grid) * unit + unit / 2]);
+ eaten = false;
+ }
+
+ // Eat food
+ if (get_loop_count() > 1 && gameobjects_overlap(snake_head, food)) {
+ eaten = true;
+ snake_length = snake_length + food_growth;
+ play_audio(eat);
+ }
+ debug_log(snake_length); // Score
+
+ // Check collision
+ if (get_loop_count() > start_length) {
+ for (let i = 0; i < snake_length; i = i + 1) {
+ if (gameobjects_overlap(snake_head, snake[i])) {
+ alive = false;
+ play_audio(lose);
+ stop_audio(bg_audio);
+ }
+ }
+ }
+});
+build_game();
+ * ```
+ * @module arcade_2d
+ * @author Titus Chew Xuan Jun
+ * @author Xenos Fiorenzo Anong
+ */
+
+export {
+ create_circle,
+ create_rectangle,
+ create_triangle,
+ create_sprite,
+ create_text,
+ query_color,
+ query_flip,
+ query_id,
+ query_pointer_position,
+ query_position,
+ query_rotation,
+ query_scale,
+ query_text,
+ update_color,
+ update_flip,
+ update_position,
+ update_rotation,
+ update_scale,
+ update_text,
+ update_to_top,
+ set_fps,
+ set_dimensions,
+ set_scale,
+ get_game_time,
+ get_loop_count,
+ enable_debug,
+ debug_log,
+ input_key_down,
+ input_left_mouse_down,
+ input_right_mouse_down,
+ pointer_over_gameobject,
+ gameobjects_overlap,
+ update_loop,
+ build_game,
+ create_audio,
+ loop_audio,
+ stop_audio,
+ play_audio,
+} from './functions';
diff --git a/src/bundles/arcade_2d/phaserScene.ts b/src/bundles/arcade_2d/phaserScene.ts
index 7eec1414c..65ade4715 100644
--- a/src/bundles/arcade_2d/phaserScene.ts
+++ b/src/bundles/arcade_2d/phaserScene.ts
@@ -1,372 +1,372 @@
-import Phaser from 'phaser';
-import {
- CircleGameObject,
- GameObject,
- type InteractableGameObject,
- RectangleGameObject,
- ShapeGameObject,
- SpriteGameObject,
- TextGameObject,
- TriangleGameObject,
-} from './gameobject';
-import {
- config,
-} from './functions';
-import {
- type TransformProps,
- type PositionXY,
- type ExceptionError,
- type PhaserGameObject,
-} from './types';
-import { AudioClip } from './audio';
-import { DEFAULT_PATH_PREFIX } from './constants';
-
-// Game state information, that changes every frame.
-export const gameState = {
- // Stores the debug information, which is reset every iteration of the update loop.
- debugLogArray: new Array(),
- // The current in-game time and frame count.
- gameTime: 0,
- loopCount: 0,
- // Store keys that are down in the Phaser Scene
- // By default, this is empty, unless a key is down
- inputKeysDown: new Set(),
- pointerProps: {
- // the current (mouse) pointer position in the canvas
- pointerPosition: [0, 0] as PositionXY,
- // true if (left mouse button) pointer down, false otherwise
- isPointerPrimaryDown: false,
- isPointerSecondaryDown: false,
- // Stores the IDs of the GameObjects that the pointer is over
- pointerOverGameObjectsId: new Set(),
- },
-};
-
-// The game state which the user can modify, through their update function.
-const userGameStateArray: Array = Array.of();
-
-/**
- * The Phaser scene that parses the GameObjects and update loop created by the user,
- * into Phaser GameObjects, and Phaser updates.
- */
-export class PhaserScene extends Phaser.Scene {
- constructor() {
- super('PhaserScene');
- }
- private sourceGameObjects: Array = GameObject.getGameObjectsArray();
- private phaserGameObjects: Array = [];
- private corsAssetsUrl: Set = new Set();
- private sourceAudioClips: Array = AudioClip.getAudioClipsArray();
- private phaserAudioClips: Array = [];
- private shouldRerenderGameObjects: boolean = true;
- private delayedKeyUpEvents: Set = new Set();
- private hasRuntimeError: boolean = false;
- private debugLogInitialErrorCount: number = 0;
- // Handle debug information
- private debugLogText: Phaser.GameObjects.Text | undefined = undefined;
-
- init() {
- gameState.debugLogArray.length = 0;
- // Disable context menu within the canvas
- this.game.canvas.oncontextmenu = (e) => e.preventDefault();
- }
-
- preload() {
- // Set the default path prefix
- this.load.setPath(DEFAULT_PATH_PREFIX);
- this.sourceGameObjects.forEach((gameObject) => {
- if (gameObject instanceof SpriteGameObject) {
- this.corsAssetsUrl.add(gameObject.getSprite().imageUrl);
- }
- });
- // Preload sprites (through Cross-Origin resource sharing (CORS))
- this.corsAssetsUrl.forEach((url) => {
- this.load.image(url, url);
- });
- // Preload audio
- this.sourceAudioClips.forEach((audioClip: AudioClip) => {
- this.load.audio(audioClip.getUrl(), audioClip.getUrl());
- });
- // Checks if loaded assets success
- this.load.on('loaderror', (file: Phaser.Loader.File) => {
- this.debugLogInitialErrorCount++;
- gameState.debugLogArray.push(`LoadError: "${file.key}" failed`);
- });
- }
-
- create() {
- this.createPhaserGameObjects();
- this.createAudioClips();
-
- // Handle keyboard inputs
- // Keyboard events can be detected inside the Source editor, which is not intended. #BUG
- this.input.keyboard.on('keydown', (event: KeyboardEvent) => {
- gameState.inputKeysDown.add(event.key);
- });
- this.input.keyboard.on('keyup', (event: KeyboardEvent) => {
- this.delayedKeyUpEvents.add(() => gameState.inputKeysDown.delete(event.key));
- });
-
- // Handle debug info
- if (!config.isDebugEnabled && !this.hasRuntimeError) {
- gameState.debugLogArray.length = 0;
- }
- this.debugLogText = this.add.text(0, 0, gameState.debugLogArray)
- .setWordWrapWidth(config.scale < 1 ? this.renderer.width * config.scale : this.renderer.width)
- .setBackgroundColor('black')
- .setAlpha(0.8)
- .setScale(config.scale < 1 ? 1 / config.scale : 1);
- }
-
- update(time, delta) {
- // Set the time and delta
- gameState.gameTime += delta;
- gameState.loopCount++;
-
- // Set the pointer properties
- gameState.pointerProps = {
- ...gameState.pointerProps,
- pointerPosition: [Math.trunc(this.input.activePointer.x), Math.trunc(this.input.activePointer.y)],
- isPointerPrimaryDown: this.input.activePointer.primaryDown,
- isPointerSecondaryDown: this.input.activePointer.rightButtonDown(),
- };
-
- this.handleUserDefinedUpdateFunction();
- this.handleGameObjectUpdates();
- this.handleAudioUpdates();
-
- // Delay KeyUp events, so that low FPS can still detect KeyDown.
- // eslint-disable-next-line array-callback-return
- this.delayedKeyUpEvents.forEach((event: Function) => event());
- this.delayedKeyUpEvents.clear();
-
- // Remove rerendering once game has been reloaded.
- this.shouldRerenderGameObjects = false;
-
- // Set and clear debug info
- if (this.debugLogText) {
- this.debugLogText.setText(gameState.debugLogArray);
- this.children.bringToTop(this.debugLogText);
- if (this.hasRuntimeError) {
- this.debugLogText.setColor('orange');
- this.sound.stopAll();
- this.scene.pause();
- } else {
- gameState.debugLogArray.length = this.debugLogInitialErrorCount;
- }
- }
- }
-
- private createPhaserGameObjects() {
- this.sourceGameObjects.forEach((gameObject) => {
- const transformProps = gameObject.getTransform();
- // Create TextGameObject
- if (gameObject instanceof TextGameObject) {
- const text = gameObject.getText().text;
- this.phaserGameObjects.push(this.add.text(
- transformProps.position[0],
- transformProps.position[1],
- text,
- ));
- this.phaserGameObjects[gameObject.id].setOrigin(0.5, 0.5);
- if (gameObject.getHitboxState().isHitboxActive) {
- this.phaserGameObjects[gameObject.id].setInteractive();
- }
- }
- // Create SpriteGameObject
- if (gameObject instanceof SpriteGameObject) {
- const url = gameObject.getSprite().imageUrl;
- this.phaserGameObjects.push(this.add.sprite(
- transformProps.position[0],
- transformProps.position[1],
- url,
- ));
- if (gameObject.getHitboxState().isHitboxActive) {
- this.phaserGameObjects[gameObject.id].setInteractive();
- }
- }
- // Create ShapeGameObject
- if (gameObject instanceof ShapeGameObject) {
- if (gameObject instanceof RectangleGameObject) {
- const shape = gameObject.getShape();
- this.phaserGameObjects.push(this.add.rectangle(
- transformProps.position[0],
- transformProps.position[1],
- shape.width,
- shape.height,
- ));
- if (gameObject.getHitboxState().isHitboxActive) {
- this.phaserGameObjects[gameObject.id].setInteractive();
- }
- }
- if (gameObject instanceof CircleGameObject) {
- const shape = gameObject.getShape();
- this.phaserGameObjects.push(this.add.circle(
- transformProps.position[0],
- transformProps.position[1],
- shape.radius,
- ));
- if (gameObject.getHitboxState().isHitboxActive) {
- this.phaserGameObjects[gameObject.id].setInteractive(
- new Phaser.Geom.Circle(
- shape.radius,
- shape.radius,
- shape.radius,
- ), Phaser.Geom.Circle.Contains,
- );
- }
- }
- if (gameObject instanceof TriangleGameObject) {
- const shape = gameObject.getShape();
- this.phaserGameObjects.push(this.add.triangle(
- transformProps.position[0],
- transformProps.position[1],
- shape.x1,
- shape.y1,
- shape.x2,
- shape.y2,
- shape.x3,
- shape.y3,
- ));
- if (gameObject.getHitboxState().isHitboxActive) {
- this.phaserGameObjects[gameObject.id].setInteractive(
- new Phaser.Geom.Triangle(
- shape.x1,
- shape.y1,
- shape.x2,
- shape.y2,
- shape.x3,
- shape.y3,
- ), Phaser.Geom.Triangle.Contains,
- );
- }
- }
- }
-
- const phaserGameObject = this.phaserGameObjects[gameObject.id];
- // Handle pointer over GameObjects
- phaserGameObject.on('pointerover', () => {
- gameState.pointerProps.pointerOverGameObjectsId.add(gameObject.id);
- });
- phaserGameObject.on('pointerout', () => {
- gameState.pointerProps.pointerOverGameObjectsId.delete(gameObject.id);
- });
-
- // Enter debug mode
- if (config.isDebugEnabled) {
- this.input.enableDebug(phaserGameObject);
- }
-
- // Store the phaserGameObject in the source representation
- gameObject.setPhaserGameObject(phaserGameObject);
- });
- }
-
- private createAudioClips() {
- try {
- this.sourceAudioClips.forEach((audioClip: AudioClip) => {
- this.phaserAudioClips.push(this.sound.add(audioClip.getUrl(), {
- loop: audioClip.shouldAudioClipLoop(),
- volume: audioClip.getVolumeLevel(),
- }));
- });
- } catch (error) {
- this.hasRuntimeError = true;
- if (error instanceof Error) {
- gameState.debugLogArray.push(`${error.name}: ${error.message}`);
- } else {
- gameState.debugLogArray.push('LoadError: Cannot load audio file');
- console.log(error);
- }
- }
- }
-
- /** Run the user-defined update function, and catch runtime errors. */
- private handleUserDefinedUpdateFunction() {
- try {
- if (!this.hasRuntimeError) {
- config.userUpdateFunction(userGameStateArray);
- }
- } catch (error) {
- this.hasRuntimeError = true;
- if (error instanceof Error) {
- gameState.debugLogArray.push(`${error.name}: ${error.message}`);
- } else {
- const exceptionError = error as ExceptionError;
- gameState.debugLogArray.push(
- `Line ${exceptionError.location.start.line}: ${exceptionError.error.name}: ${exceptionError.error.message}`,
- );
- }
- }
- }
-
- /** Loop through each GameObject in the array and determine which needs to update. */
- private handleGameObjectUpdates() {
- this.sourceGameObjects.forEach((gameObject: InteractableGameObject) => {
- const phaserGameObject = this.phaserGameObjects[gameObject.id] as PhaserGameObject;
- if (phaserGameObject) {
- // Update the transform of Phaser GameObject
- if (gameObject.hasTransformUpdates() || this.shouldRerenderGameObjects) {
- const transformProps = gameObject.getTransform() as TransformProps;
- phaserGameObject.setPosition(transformProps.position[0], transformProps.position[1])
- .setRotation(transformProps.rotation)
- .setScale(transformProps.scale[0], transformProps.scale[1]);
- if (gameObject instanceof TriangleGameObject) {
- // The only shape that requires flipping is the triangle, as the rest are symmetric about their origin.
- phaserGameObject.setRotation(transformProps.rotation + (gameObject.getFlipState()[1] ? Math.PI : 0));
- }
- gameObject.setTransformUpdated();
- }
-
- // Update the image of Phaser GameObject
- if (gameObject.hasRenderUpdates() || this.shouldRerenderGameObjects) {
- const color = gameObject.getColor();
- // eslint-disable-next-line new-cap
- const intColor = Phaser.Display.Color.GetColor32(color[0], color[1], color[2], color[3]);
- const flip = gameObject.getFlipState();
- if (gameObject instanceof TextGameObject) {
- (phaserGameObject as Phaser.GameObjects.Text).setTint(intColor)
- .setAlpha(color[3] / 255)
- .setFlip(flip[0], flip[1])
- .setText(gameObject.getText().text);
- } else if (gameObject instanceof SpriteGameObject) {
- (phaserGameObject as Phaser.GameObjects.Sprite).setTint(intColor)
- .setAlpha(color[3] / 255)
- .setFlip(flip[0], flip[1]);
- } else if (gameObject instanceof ShapeGameObject) {
- (phaserGameObject as Phaser.GameObjects.Shape).setFillStyle(intColor, color[3] / 255)
- // Phaser.GameObjects.Shape does not have setFlip, so flipping is done with rotations.
- // The only shape that requires flipping is the triangle, as the rest are symmetric about their origin.
- .setRotation(gameObject.getTransform().rotation + (flip[1] ? Math.PI : 0));
- }
- // Update the z-index (rendering order), to the top.
- if (gameObject.getShouldBringToTop()) {
- this.children.bringToTop(phaserGameObject);
- }
- gameObject.setRenderUpdated();
- }
- } else {
- this.hasRuntimeError = true;
- gameState.debugLogArray.push('RuntimeError: GameObject error in update_loop');
- }
- });
- }
-
- private handleAudioUpdates() {
- this.sourceAudioClips.forEach((audioClip: AudioClip) => {
- if (audioClip.hasAudioClipUpdates()) {
- const phaserAudioClip = this.phaserAudioClips[audioClip.id] as Phaser.Sound.BaseSound;
- if (phaserAudioClip) {
- if (audioClip.shouldAudioClipPlay()) {
- phaserAudioClip.play();
- } else {
- phaserAudioClip.stop();
- }
- } else {
- this.hasRuntimeError = true;
- gameState.debugLogArray.push('RuntimeError: Audio error in update_loop');
- }
- }
- });
- }
-}
+import Phaser from 'phaser';
+import {
+ CircleGameObject,
+ GameObject,
+ type InteractableGameObject,
+ RectangleGameObject,
+ ShapeGameObject,
+ SpriteGameObject,
+ TextGameObject,
+ TriangleGameObject,
+} from './gameobject';
+import {
+ config,
+} from './functions';
+import {
+ type TransformProps,
+ type PositionXY,
+ type ExceptionError,
+ type PhaserGameObject,
+} from './types';
+import { AudioClip } from './audio';
+import { DEFAULT_PATH_PREFIX } from './constants';
+
+// Game state information, that changes every frame.
+export const gameState = {
+ // Stores the debug information, which is reset every iteration of the update loop.
+ debugLogArray: new Array(),
+ // The current in-game time and frame count.
+ gameTime: 0,
+ loopCount: 0,
+ // Store keys that are down in the Phaser Scene
+ // By default, this is empty, unless a key is down
+ inputKeysDown: new Set(),
+ pointerProps: {
+ // the current (mouse) pointer position in the canvas
+ pointerPosition: [0, 0] as PositionXY,
+ // true if (left mouse button) pointer down, false otherwise
+ isPointerPrimaryDown: false,
+ isPointerSecondaryDown: false,
+ // Stores the IDs of the GameObjects that the pointer is over
+ pointerOverGameObjectsId: new Set(),
+ },
+};
+
+// The game state which the user can modify, through their update function.
+const userGameStateArray: Array = Array.of();
+
+/**
+ * The Phaser scene that parses the GameObjects and update loop created by the user,
+ * into Phaser GameObjects, and Phaser updates.
+ */
+export class PhaserScene extends Phaser.Scene {
+ constructor() {
+ super('PhaserScene');
+ }
+ private sourceGameObjects: Array = GameObject.getGameObjectsArray();
+ private phaserGameObjects: Array = [];
+ private corsAssetsUrl: Set = new Set();
+ private sourceAudioClips: Array = AudioClip.getAudioClipsArray();
+ private phaserAudioClips: Array = [];
+ private shouldRerenderGameObjects: boolean = true;
+ private delayedKeyUpEvents: Set = new Set();
+ private hasRuntimeError: boolean = false;
+ private debugLogInitialErrorCount: number = 0;
+ // Handle debug information
+ private debugLogText: Phaser.GameObjects.Text | undefined = undefined;
+
+ init() {
+ gameState.debugLogArray.length = 0;
+ // Disable context menu within the canvas
+ this.game.canvas.oncontextmenu = (e) => e.preventDefault();
+ }
+
+ preload() {
+ // Set the default path prefix
+ this.load.setPath(DEFAULT_PATH_PREFIX);
+ this.sourceGameObjects.forEach((gameObject) => {
+ if (gameObject instanceof SpriteGameObject) {
+ this.corsAssetsUrl.add(gameObject.getSprite().imageUrl);
+ }
+ });
+ // Preload sprites (through Cross-Origin resource sharing (CORS))
+ this.corsAssetsUrl.forEach((url) => {
+ this.load.image(url, url);
+ });
+ // Preload audio
+ this.sourceAudioClips.forEach((audioClip: AudioClip) => {
+ this.load.audio(audioClip.getUrl(), audioClip.getUrl());
+ });
+ // Checks if loaded assets success
+ this.load.on('loaderror', (file: Phaser.Loader.File) => {
+ this.debugLogInitialErrorCount++;
+ gameState.debugLogArray.push(`LoadError: "${file.key}" failed`);
+ });
+ }
+
+ create() {
+ this.createPhaserGameObjects();
+ this.createAudioClips();
+
+ // Handle keyboard inputs
+ // Keyboard events can be detected inside the Source editor, which is not intended. #BUG
+ this.input.keyboard.on('keydown', (event: KeyboardEvent) => {
+ gameState.inputKeysDown.add(event.key);
+ });
+ this.input.keyboard.on('keyup', (event: KeyboardEvent) => {
+ this.delayedKeyUpEvents.add(() => gameState.inputKeysDown.delete(event.key));
+ });
+
+ // Handle debug info
+ if (!config.isDebugEnabled && !this.hasRuntimeError) {
+ gameState.debugLogArray.length = 0;
+ }
+ this.debugLogText = this.add.text(0, 0, gameState.debugLogArray)
+ .setWordWrapWidth(config.scale < 1 ? this.renderer.width * config.scale : this.renderer.width)
+ .setBackgroundColor('black')
+ .setAlpha(0.8)
+ .setScale(config.scale < 1 ? 1 / config.scale : 1);
+ }
+
+ update(time, delta) {
+ // Set the time and delta
+ gameState.gameTime += delta;
+ gameState.loopCount++;
+
+ // Set the pointer properties
+ gameState.pointerProps = {
+ ...gameState.pointerProps,
+ pointerPosition: [Math.trunc(this.input.activePointer.x), Math.trunc(this.input.activePointer.y)],
+ isPointerPrimaryDown: this.input.activePointer.primaryDown,
+ isPointerSecondaryDown: this.input.activePointer.rightButtonDown(),
+ };
+
+ this.handleUserDefinedUpdateFunction();
+ this.handleGameObjectUpdates();
+ this.handleAudioUpdates();
+
+ // Delay KeyUp events, so that low FPS can still detect KeyDown.
+ // eslint-disable-next-line array-callback-return
+ this.delayedKeyUpEvents.forEach((event: Function) => event());
+ this.delayedKeyUpEvents.clear();
+
+ // Remove rerendering once game has been reloaded.
+ this.shouldRerenderGameObjects = false;
+
+ // Set and clear debug info
+ if (this.debugLogText) {
+ this.debugLogText.setText(gameState.debugLogArray);
+ this.children.bringToTop(this.debugLogText);
+ if (this.hasRuntimeError) {
+ this.debugLogText.setColor('orange');
+ this.sound.stopAll();
+ this.scene.pause();
+ } else {
+ gameState.debugLogArray.length = this.debugLogInitialErrorCount;
+ }
+ }
+ }
+
+ private createPhaserGameObjects() {
+ this.sourceGameObjects.forEach((gameObject) => {
+ const transformProps = gameObject.getTransform();
+ // Create TextGameObject
+ if (gameObject instanceof TextGameObject) {
+ const text = gameObject.getText().text;
+ this.phaserGameObjects.push(this.add.text(
+ transformProps.position[0],
+ transformProps.position[1],
+ text,
+ ));
+ this.phaserGameObjects[gameObject.id].setOrigin(0.5, 0.5);
+ if (gameObject.getHitboxState().isHitboxActive) {
+ this.phaserGameObjects[gameObject.id].setInteractive();
+ }
+ }
+ // Create SpriteGameObject
+ if (gameObject instanceof SpriteGameObject) {
+ const url = gameObject.getSprite().imageUrl;
+ this.phaserGameObjects.push(this.add.sprite(
+ transformProps.position[0],
+ transformProps.position[1],
+ url,
+ ));
+ if (gameObject.getHitboxState().isHitboxActive) {
+ this.phaserGameObjects[gameObject.id].setInteractive();
+ }
+ }
+ // Create ShapeGameObject
+ if (gameObject instanceof ShapeGameObject) {
+ if (gameObject instanceof RectangleGameObject) {
+ const shape = gameObject.getShape();
+ this.phaserGameObjects.push(this.add.rectangle(
+ transformProps.position[0],
+ transformProps.position[1],
+ shape.width,
+ shape.height,
+ ));
+ if (gameObject.getHitboxState().isHitboxActive) {
+ this.phaserGameObjects[gameObject.id].setInteractive();
+ }
+ }
+ if (gameObject instanceof CircleGameObject) {
+ const shape = gameObject.getShape();
+ this.phaserGameObjects.push(this.add.circle(
+ transformProps.position[0],
+ transformProps.position[1],
+ shape.radius,
+ ));
+ if (gameObject.getHitboxState().isHitboxActive) {
+ this.phaserGameObjects[gameObject.id].setInteractive(
+ new Phaser.Geom.Circle(
+ shape.radius,
+ shape.radius,
+ shape.radius,
+ ), Phaser.Geom.Circle.Contains,
+ );
+ }
+ }
+ if (gameObject instanceof TriangleGameObject) {
+ const shape = gameObject.getShape();
+ this.phaserGameObjects.push(this.add.triangle(
+ transformProps.position[0],
+ transformProps.position[1],
+ shape.x1,
+ shape.y1,
+ shape.x2,
+ shape.y2,
+ shape.x3,
+ shape.y3,
+ ));
+ if (gameObject.getHitboxState().isHitboxActive) {
+ this.phaserGameObjects[gameObject.id].setInteractive(
+ new Phaser.Geom.Triangle(
+ shape.x1,
+ shape.y1,
+ shape.x2,
+ shape.y2,
+ shape.x3,
+ shape.y3,
+ ), Phaser.Geom.Triangle.Contains,
+ );
+ }
+ }
+ }
+
+ const phaserGameObject = this.phaserGameObjects[gameObject.id];
+ // Handle pointer over GameObjects
+ phaserGameObject.on('pointerover', () => {
+ gameState.pointerProps.pointerOverGameObjectsId.add(gameObject.id);
+ });
+ phaserGameObject.on('pointerout', () => {
+ gameState.pointerProps.pointerOverGameObjectsId.delete(gameObject.id);
+ });
+
+ // Enter debug mode
+ if (config.isDebugEnabled) {
+ this.input.enableDebug(phaserGameObject);
+ }
+
+ // Store the phaserGameObject in the source representation
+ gameObject.setPhaserGameObject(phaserGameObject);
+ });
+ }
+
+ private createAudioClips() {
+ try {
+ this.sourceAudioClips.forEach((audioClip: AudioClip) => {
+ this.phaserAudioClips.push(this.sound.add(audioClip.getUrl(), {
+ loop: audioClip.shouldAudioClipLoop(),
+ volume: audioClip.getVolumeLevel(),
+ }));
+ });
+ } catch (error) {
+ this.hasRuntimeError = true;
+ if (error instanceof Error) {
+ gameState.debugLogArray.push(`${error.name}: ${error.message}`);
+ } else {
+ gameState.debugLogArray.push('LoadError: Cannot load audio file');
+ console.log(error);
+ }
+ }
+ }
+
+ /** Run the user-defined update function, and catch runtime errors. */
+ private handleUserDefinedUpdateFunction() {
+ try {
+ if (!this.hasRuntimeError) {
+ config.userUpdateFunction(userGameStateArray);
+ }
+ } catch (error) {
+ this.hasRuntimeError = true;
+ if (error instanceof Error) {
+ gameState.debugLogArray.push(`${error.name}: ${error.message}`);
+ } else {
+ const exceptionError = error as ExceptionError;
+ gameState.debugLogArray.push(
+ `Line ${exceptionError.location.start.line}: ${exceptionError.error.name}: ${exceptionError.error.message}`,
+ );
+ }
+ }
+ }
+
+ /** Loop through each GameObject in the array and determine which needs to update. */
+ private handleGameObjectUpdates() {
+ this.sourceGameObjects.forEach((gameObject: InteractableGameObject) => {
+ const phaserGameObject = this.phaserGameObjects[gameObject.id] as PhaserGameObject;
+ if (phaserGameObject) {
+ // Update the transform of Phaser GameObject
+ if (gameObject.hasTransformUpdates() || this.shouldRerenderGameObjects) {
+ const transformProps = gameObject.getTransform() as TransformProps;
+ phaserGameObject.setPosition(transformProps.position[0], transformProps.position[1])
+ .setRotation(transformProps.rotation)
+ .setScale(transformProps.scale[0], transformProps.scale[1]);
+ if (gameObject instanceof TriangleGameObject) {
+ // The only shape that requires flipping is the triangle, as the rest are symmetric about their origin.
+ phaserGameObject.setRotation(transformProps.rotation + (gameObject.getFlipState()[1] ? Math.PI : 0));
+ }
+ gameObject.setTransformUpdated();
+ }
+
+ // Update the image of Phaser GameObject
+ if (gameObject.hasRenderUpdates() || this.shouldRerenderGameObjects) {
+ const color = gameObject.getColor();
+ // eslint-disable-next-line new-cap
+ const intColor = Phaser.Display.Color.GetColor32(color[0], color[1], color[2], color[3]);
+ const flip = gameObject.getFlipState();
+ if (gameObject instanceof TextGameObject) {
+ (phaserGameObject as Phaser.GameObjects.Text).setTint(intColor)
+ .setAlpha(color[3] / 255)
+ .setFlip(flip[0], flip[1])
+ .setText(gameObject.getText().text);
+ } else if (gameObject instanceof SpriteGameObject) {
+ (phaserGameObject as Phaser.GameObjects.Sprite).setTint(intColor)
+ .setAlpha(color[3] / 255)
+ .setFlip(flip[0], flip[1]);
+ } else if (gameObject instanceof ShapeGameObject) {
+ (phaserGameObject as Phaser.GameObjects.Shape).setFillStyle(intColor, color[3] / 255)
+ // Phaser.GameObjects.Shape does not have setFlip, so flipping is done with rotations.
+ // The only shape that requires flipping is the triangle, as the rest are symmetric about their origin.
+ .setRotation(gameObject.getTransform().rotation + (flip[1] ? Math.PI : 0));
+ }
+ // Update the z-index (rendering order), to the top.
+ if (gameObject.getShouldBringToTop()) {
+ this.children.bringToTop(phaserGameObject);
+ }
+ gameObject.setRenderUpdated();
+ }
+ } else {
+ this.hasRuntimeError = true;
+ gameState.debugLogArray.push('RuntimeError: GameObject error in update_loop');
+ }
+ });
+ }
+
+ private handleAudioUpdates() {
+ this.sourceAudioClips.forEach((audioClip: AudioClip) => {
+ if (audioClip.hasAudioClipUpdates()) {
+ const phaserAudioClip = this.phaserAudioClips[audioClip.id] as Phaser.Sound.BaseSound;
+ if (phaserAudioClip) {
+ if (audioClip.shouldAudioClipPlay()) {
+ phaserAudioClip.play();
+ } else {
+ phaserAudioClip.stop();
+ }
+ } else {
+ this.hasRuntimeError = true;
+ gameState.debugLogArray.push('RuntimeError: Audio error in update_loop');
+ }
+ }
+ });
+ }
+}
diff --git a/src/bundles/arcade_2d/types.ts b/src/bundles/arcade_2d/types.ts
index 6104c451e..dafa6c61e 100644
--- a/src/bundles/arcade_2d/types.ts
+++ b/src/bundles/arcade_2d/types.ts
@@ -1,127 +1,127 @@
-/**
- * This file contains the types used to represent GameObjects.
- */
-
-/** Represents (x,y) worldspace position of a GameObject. */
-export type PositionXY = [number, number];
-
-/** Represents (x,y) worldspace scale of a GameObject. */
-export type ScaleXY = [number, number];
-
-/** Represents the (width, height) dimensions of the game canvas. */
-export type DimensionsXY = [number, number];
-
-/** Represents the (red, green, blue, alpha) of a color of a GameObject. */
-export type ColorRGBA = [number, number, number, number];
-
-/** Represents (x,y) flip state of GameObject. */
-export type FlipXY = [boolean, boolean];
-
-/**
- * Represents transform properties of a GameObject in worldspace.
- * @property {PositionXY} position - The (x,y) worldspace position of the GameObject.
- * @property {ScaleXY} xScale - The (x,y) worldspace scale of the GameObject.
- * @property {number} rotation - The worldspace rotation of the GameObject, in radians counter-clockwise.
- */
-export type TransformProps = {
- position: PositionXY;
- scale: ScaleXY;
- rotation: number;
-};
-
-/**
- * Represents the render properties of a GameObject.
- * @property {ColorRGBA} color - The color associated with the GameObject tint.
- * @property {FlipXY} flip - The (x,y) flip state of the GameObject.
- * @property {boolean} visible - The render-visibility of the GameObject.
- */
-export type RenderProps = {
- color: ColorRGBA;
- flip: FlipXY;
- isVisible: boolean;
-};
-
-/**
- * Represents the interactable properties of a GameObject.
- * @property {boolean} isHitboxActive - The interactable state of the hitbox associated with the GameObject in the canvas.
- */
-export type InteractableProps = {
- isHitboxActive: boolean;
-};
-
-/**
- * Represents a rectangle of a GameObject's shape.
- */
-export type RectangleProps = {
- width: number;
- height: number;
-};
-
-/**
- * Represents a isosceles triangular shape of a GameObject's shape.
- */
-export type TriangleProps = {
- x1: number;
- y1: number;
- x2: number;
- y2: number;
- x3: number;
- y3: number;
-};
-
-/**
- * Represents a circular shape of a GameObject's shape.
- */
-export type CircleProps = {
- radius: number;
-};
-
-/**
- * Represents the rendered text of a GameObject.
- */
-export type DisplayText = {
- text: string;
-};
-
-/**
- * Represents the rendered sprite of a GameObject.
- */
-export type Sprite = {
- imageUrl: string;
-};
-
-// =============================================================================
-// Other types
-// =============================================================================
-
-/**
- * Represent the return type of build_game(), which is accessed in the debuggerContext.result.value.
- */
-export type BuildGame = {
- toReplString: () => string;
- gameConfig;
-};
-
-/**
- * Represents an update function that is user-defined.
- * userSuppliedState is an array that stores whatever the user sets it to be,
- * which can be assessed and modified on the next frame.
- */
-export type UpdateFunction = (userSuppliedState: Array) => void;
-
-/**
- * Represents a runtime error, that is not an instance of Error.
- */
-export type ExceptionError = {
- error: Error;
- location: {
- start: {
- line: number;
- };
- };
-};
-
-/**
- * Represents the Phaser Game Object types that are used.
- */
-export type PhaserGameObject = Phaser.GameObjects.Sprite | Phaser.GameObjects.Text | Phaser.GameObjects.Shape;
+/**
+ * This file contains the types used to represent GameObjects.
+ */
+
+/** Represents (x,y) worldspace position of a GameObject. */
+export type PositionXY = [number, number];
+
+/** Represents (x,y) worldspace scale of a GameObject. */
+export type ScaleXY = [number, number];
+
+/** Represents the (width, height) dimensions of the game canvas. */
+export type DimensionsXY = [number, number];
+
+/** Represents the (red, green, blue, alpha) of a color of a GameObject. */
+export type ColorRGBA = [number, number, number, number];
+
+/** Represents (x,y) flip state of GameObject. */
+export type FlipXY = [boolean, boolean];
+
+/**
+ * Represents transform properties of a GameObject in worldspace.
+ * @property {PositionXY} position - The (x,y) worldspace position of the GameObject.
+ * @property {ScaleXY} xScale - The (x,y) worldspace scale of the GameObject.
+ * @property {number} rotation - The worldspace rotation of the GameObject, in radians counter-clockwise.
+ */
+export type TransformProps = {
+ position: PositionXY;
+ scale: ScaleXY;
+ rotation: number;
+};
+
+/**
+ * Represents the render properties of a GameObject.
+ * @property {ColorRGBA} color - The color associated with the GameObject tint.
+ * @property {FlipXY} flip - The (x,y) flip state of the GameObject.
+ * @property {boolean} visible - The render-visibility of the GameObject.
+ */
+export type RenderProps = {
+ color: ColorRGBA;
+ flip: FlipXY;
+ isVisible: boolean;
+};
+
+/**
+ * Represents the interactable properties of a GameObject.
+ * @property {boolean} isHitboxActive - The interactable state of the hitbox associated with the GameObject in the canvas.
+ */
+export type InteractableProps = {
+ isHitboxActive: boolean;
+};
+
+/**
+ * Represents a rectangle of a GameObject's shape.
+ */
+export type RectangleProps = {
+ width: number;
+ height: number;
+};
+
+/**
+ * Represents a isosceles triangular shape of a GameObject's shape.
+ */
+export type TriangleProps = {
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ x3: number;
+ y3: number;
+};
+
+/**
+ * Represents a circular shape of a GameObject's shape.
+ */
+export type CircleProps = {
+ radius: number;
+};
+
+/**
+ * Represents the rendered text of a GameObject.
+ */
+export type DisplayText = {
+ text: string;
+};
+
+/**
+ * Represents the rendered sprite of a GameObject.
+ */
+export type Sprite = {
+ imageUrl: string;
+};
+
+// =============================================================================
+// Other types
+// =============================================================================
+
+/**
+ * Represent the return type of build_game(), which is accessed in the debuggerContext.result.value.
+ */
+export type BuildGame = {
+ toReplString: () => string;
+ gameConfig;
+};
+
+/**
+ * Represents an update function that is user-defined.
+ * userSuppliedState is an array that stores whatever the user sets it to be,
+ * which can be assessed and modified on the next frame.
+ */
+export type UpdateFunction = (userSuppliedState: Array) => void;
+
+/**
+ * Represents a runtime error, that is not an instance of Error.
+ */
+export type ExceptionError = {
+ error: Error;
+ location: {
+ start: {
+ line: number;
+ };
+ };
+};
+
+/**
+ * Represents the Phaser Game Object types that are used.
+ */
+export type PhaserGameObject = Phaser.GameObjects.Sprite | Phaser.GameObjects.Text | Phaser.GameObjects.Shape;
diff --git a/src/bundles/binary_tree/functions.ts b/src/bundles/binary_tree/functions.ts
index f15d8e8c5..1988d3279 100644
--- a/src/bundles/binary_tree/functions.ts
+++ b/src/bundles/binary_tree/functions.ts
@@ -1,149 +1,142 @@
-/**
- * The `binary_tree` Source Module provides functions for the interaction with binary trees, as covered the textbook
- * [Structure and Interpretation of Computer Programs, JavaScript Adaptation (SICP JS)](https://sourceacademy.org/sicpjs)
- * in [section 2.3.3 Example: Representing Sets](https://sourceacademy.org/sicpjs/2.3.3#h3).
- * Click on a function name in the index below to see how the function is defined and used.
- * @module binary_tree
- */
-import type { BinaryTree } from './types';
-
-/**
- * Returns an empty binary tree, represented by the empty list null
- * @example
- * ```typescript
- * display(make_empty_tree()); // Shows "null" in the REPL
- * ```
- * @return An empty binary tree
- */
-
-export function make_empty_tree(): BinaryTree {
- return null;
-}
-
-/**
- * Returns a binary tree node composed of the specified left subtree, value and right subtree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
- * display(tree); // Shows "[1, [null, [null, null]]]" in the REPL
- * ```
- * @param value Value to be stored in the node
- * @param left Left subtree of the node
- * @param right Right subtree of the node
- * @returns A binary tree
- */
-export function make_tree(
- value: any,
- left: BinaryTree,
- right: BinaryTree,
-): BinaryTree {
- return [value, [left, [right, null]]];
-}
-
-/**
- * Returns a boolean value, indicating whether the given
- * value is a binary tree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
- * display(is_tree(tree)); // Shows "true" in the REPL
- * ```
- * @param v Value to be tested
- * @returns bool
- */
-export function is_tree(
- value: any,
-): boolean {
- return value === null
- || (Array.isArray(value)
- && value.length === 2
- && Array.isArray(value[1])
- && value[1].length === 2
- && is_tree(value[1][0])
- && value[1][1].length === 2
- && is_tree(value[1][1][0])
- && value[1][1][1] === null);
-}
-
-/**
- * Returns a boolean value, indicating whether the given
- * value is an empty binary tree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
- * display(is_empty_tree(tree)); // Shows "false" in the REPL
- * ```
- * @param v Value to be tested
- * @returns bool
- */
-export function is_empty_tree(
- value: any,
-): boolean {
- return value === null;
-}
-
-/**
- * Returns the entry of a given binary tree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
- * display(entry(tree)); // Shows "1" in the REPL
- * ```
- * @param t BinaryTree to be accessed
- * @returns Value
- */
-export function entry(
- t: BinaryTree,
-): boolean {
- if (Array.isArray(t) && t.length === 2) {
- return t[0];
- }
- throw new Error(
- `function entry expects binary tree, received: ${t}`,
- );
-}
-
-/**
- * Returns the left branch of a given binary tree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_tree(2, make_empty_tree(), make_empty_tree()), make_empty_tree());
- * display(entry(left_branch(tree))); // Shows "2" in the REPL
- * ```
- * @param t BinaryTree to be accessed
- * @returns BinaryTree
- */
-export function left_branch(
- t: BinaryTree,
-): BinaryTree {
- if (Array.isArray(t) && t.length === 2
- && Array.isArray(t[1]) && t[1].length === 2) {
- return t[1][0];
- }
- throw new Error(
- `function left_branch expects binary tree, received: ${t}`,
- );
-}
-
-/**
- * Returns the right branch of a given binary tree.
- * @example
- * ```typescript
- * const tree = make_tree(1, make_empty_tree(), make_tree(2, make_empty_tree(), make_empty_tree()));
- * display(entry(right_branch(tree))); // Shows "2" in the REPL
- * ```
- * @param t BinaryTree to be accessed
- * @returns BinaryTree
- */
-export function right_branch(
- t: BinaryTree,
-): BinaryTree {
- if (Array.isArray(t) && t.length === 2
- && Array.isArray(t[1]) && t[1].length === 2
- && Array.isArray(t[1][1]) && t[1][1].length === 2) {
- return t[1][1][0];
- }
- throw new Error(
- `function right_branch expects binary tree, received: ${t}`,
- );
-}
+import type { BinaryTree } from './types';
+
+/**
+ * Returns an empty binary tree, represented by the empty list null
+ * @example
+ * ```typescript
+ * display(make_empty_tree()); // Shows "null" in the REPL
+ * ```
+ * @return An empty binary tree
+ */
+
+export function make_empty_tree(): BinaryTree {
+ return null;
+}
+
+/**
+ * Returns a binary tree node composed of the specified left subtree, value and right subtree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
+ * display(tree); // Shows "[1, [null, [null, null]]]" in the REPL
+ * ```
+ * @param value Value to be stored in the node
+ * @param left Left subtree of the node
+ * @param right Right subtree of the node
+ * @returns A binary tree
+ */
+export function make_tree(
+ value: any,
+ left: BinaryTree,
+ right: BinaryTree,
+): BinaryTree {
+ return [value, [left, [right, null]]];
+}
+
+/**
+ * Returns a boolean value, indicating whether the given
+ * value is a binary tree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
+ * display(is_tree(tree)); // Shows "true" in the REPL
+ * ```
+ * @param v Value to be tested
+ * @returns bool
+ */
+export function is_tree(
+ value: any,
+): boolean {
+ return value === null
+ || (Array.isArray(value)
+ && value.length === 2
+ && Array.isArray(value[1])
+ && value[1].length === 2
+ && is_tree(value[1][0])
+ && value[1][1].length === 2
+ && is_tree(value[1][1][0])
+ && value[1][1][1] === null);
+}
+
+/**
+ * Returns a boolean value, indicating whether the given
+ * value is an empty binary tree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
+ * display(is_empty_tree(tree)); // Shows "false" in the REPL
+ * ```
+ * @param v Value to be tested
+ * @returns bool
+ */
+export function is_empty_tree(
+ value: any,
+): boolean {
+ return value === null;
+}
+
+/**
+ * Returns the entry of a given binary tree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_empty_tree(), make_empty_tree());
+ * display(entry(tree)); // Shows "1" in the REPL
+ * ```
+ * @param t BinaryTree to be accessed
+ * @returns Value
+ */
+export function entry(
+ t: BinaryTree,
+): boolean {
+ if (Array.isArray(t) && t.length === 2) {
+ return t[0];
+ }
+ throw new Error(
+ `function entry expects binary tree, received: ${t}`,
+ );
+}
+
+/**
+ * Returns the left branch of a given binary tree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_tree(2, make_empty_tree(), make_empty_tree()), make_empty_tree());
+ * display(entry(left_branch(tree))); // Shows "2" in the REPL
+ * ```
+ * @param t BinaryTree to be accessed
+ * @returns BinaryTree
+ */
+export function left_branch(
+ t: BinaryTree,
+): BinaryTree {
+ if (Array.isArray(t) && t.length === 2
+ && Array.isArray(t[1]) && t[1].length === 2) {
+ return t[1][0];
+ }
+ throw new Error(
+ `function left_branch expects binary tree, received: ${t}`,
+ );
+}
+
+/**
+ * Returns the right branch of a given binary tree.
+ * @example
+ * ```typescript
+ * const tree = make_tree(1, make_empty_tree(), make_tree(2, make_empty_tree(), make_empty_tree()));
+ * display(entry(right_branch(tree))); // Shows "2" in the REPL
+ * ```
+ * @param t BinaryTree to be accessed
+ * @returns BinaryTree
+ */
+export function right_branch(
+ t: BinaryTree,
+): BinaryTree {
+ if (Array.isArray(t) && t.length === 2
+ && Array.isArray(t[1]) && t[1].length === 2
+ && Array.isArray(t[1][1]) && t[1][1].length === 2) {
+ return t[1][1][0];
+ }
+ throw new Error(
+ `function right_branch expects binary tree, received: ${t}`,
+ );
+}
diff --git a/src/bundles/binary_tree/index.ts b/src/bundles/binary_tree/index.ts
index a4f3f50e8..4b9d3c3b8 100644
--- a/src/bundles/binary_tree/index.ts
+++ b/src/bundles/binary_tree/index.ts
@@ -1,10 +1,14 @@
-/**
- * Binary Tree abstraction for Source Academy
- * @author Martin Henz
- * @author Joel Lee
- * @author Loh Xian Ze, Bryan
- */
-export {
- entry, is_empty_tree, is_tree, left_branch,
- make_empty_tree, make_tree, right_branch,
-} from './functions';
+/**
+ * The `binary_tree` Source Module provides functions for the interaction with binary trees, as covered the textbook
+ * [Structure and Interpretation of Computer Programs, JavaScript Adaptation (SICP JS)](https://sourceacademy.org/sicpjs)
+ * in [section 2.3.3 Example: Representing Sets](https://sourceacademy.org/sicpjs/2.3.3#h3).
+ * Click on a function name in the index below to see how the function is defined and used.
+ * @module binary_tree
+ * @author Martin Henz
+ * @author Joel Lee
+ * @author Loh Xian Ze, Bryan
+ */
+export {
+ entry, is_empty_tree, is_tree, left_branch,
+ make_empty_tree, make_tree, right_branch,
+} from './functions';
diff --git a/src/bundles/binary_tree/types.ts b/src/bundles/binary_tree/types.ts
index 877d19ab8..9976428b1 100644
--- a/src/bundles/binary_tree/types.ts
+++ b/src/bundles/binary_tree/types.ts
@@ -1 +1 @@
-export type BinaryTree = (BinaryTree | any)[] | null;
+export type BinaryTree = (BinaryTree | any)[] | null;
diff --git a/src/bundles/copy_gc/index.ts b/src/bundles/copy_gc/index.ts
index 1467e47b8..34aa0812a 100644
--- a/src/bundles/copy_gc/index.ts
+++ b/src/bundles/copy_gc/index.ts
@@ -1,507 +1,507 @@
-import { COMMAND, type CommandHeapObject, type Memory, type MemoryHeaps, type Tag } from './types';
-
-// Global Variables
-let ROW: number = 10;
-const COLUMN: number = 32;
-let MEMORY_SIZE: number = -99;
-let TO_SPACE: number;
-let FROM_SPACE: number;
-let memory: Memory;
-let memoryHeaps: Memory[] = [];
-const commandHeap: CommandHeapObject[] = [];
-let toMemoryMatrix: number[][];
-let fromMemoryMatrix: number[][];
-let tags: Tag[];
-let typeTag: string[];
-const flips: number[] = [];
-let TAG_SLOT: number = 0;
-let SIZE_SLOT: number = 1;
-let FIRST_CHILD_SLOT: number = 2;
-let LAST_CHILD_SLOT: number = 3;
-let ROOTS: number[] = [];
-
-function initialize_tag(allTag: number[], types: string[]): void {
- tags = allTag;
- typeTag = types;
-}
-
-function allHeap(newHeap: number[][]): void {
- memoryHeaps = newHeap;
-}
-
-function updateFlip(): void {
- flips.push(commandHeap.length - 1);
-}
-
-function generateMemory(): void {
- toMemoryMatrix = [];
- for (let i = 0; i < ROW / 2; i += 1) {
- memory = [];
- for (let j = 0; j < COLUMN && i * COLUMN + j < MEMORY_SIZE / 2; j += 1) {
- memory.push(i * COLUMN + j);
- }
- toMemoryMatrix.push(memory);
- }
-
- fromMemoryMatrix = [];
- for (let i = ROW / 2; i < ROW; i += 1) {
- memory = [];
- for (let j = 0; j < COLUMN && i * COLUMN + j < MEMORY_SIZE; j += 1) {
- memory.push(i * COLUMN + j);
- }
- fromMemoryMatrix.push(memory);
- }
-
- const obj: CommandHeapObject = {
- type: COMMAND.INIT,
- to: TO_SPACE,
- from: FROM_SPACE,
- heap: [],
- left: -1,
- right: -1,
- sizeLeft: 0,
- sizeRight: 0,
- desc: 'Memory initially empty.',
- leftDesc: '',
- rightDesc: '',
- scan: -1,
- free: -1,
- };
-
- commandHeap.push(obj);
-}
-
-function resetFromSpace(fromSpace, heap): number[] {
- const newHeap: number[] = [];
- if (fromSpace > 0) {
- for (let i = 0; i < MEMORY_SIZE / 2; i += 1) {
- newHeap.push(heap[i]);
- }
- for (let i = MEMORY_SIZE / 2; i < MEMORY_SIZE; i += 1) {
- newHeap.push(0);
- }
- } else {
- // to space between 0...M/2
- for (let i = 0; i < MEMORY_SIZE / 2; i += 1) {
- newHeap.push(0);
- }
- for (let i = MEMORY_SIZE / 2; i < MEMORY_SIZE; i += 1) {
- newHeap.push(heap[i]);
- }
- }
- return newHeap;
-}
-
-function initialize_memory(memorySize: number): void {
- MEMORY_SIZE = memorySize;
- ROW = MEMORY_SIZE / COLUMN;
- TO_SPACE = 0;
- FROM_SPACE = MEMORY_SIZE / 2;
- generateMemory();
-}
-
-function newCommand(
- type,
- toSpace,
- fromSpace,
- left,
- right,
- sizeLeft,
- sizeRight,
- heap,
- description,
- firstDesc,
- lastDesc,
-): void {
- const newType = type;
- const newToSpace = toSpace;
- const newFromSpace = fromSpace;
- const newLeft = left;
- const newRight = right;
- const newSizeLeft = sizeLeft;
- const newSizeRight = sizeRight;
- const newDesc = description;
- const newFirstDesc = firstDesc;
- const newLastDesc = lastDesc;
-
- memory = [];
- for (let j = 0; j < heap.length; j += 1) {
- memory.push(heap[j]);
- }
-
- const obj: CommandHeapObject = {
- type: newType,
- to: newToSpace,
- from: newFromSpace,
- heap: memory,
- left: newLeft,
- right: newRight,
- sizeLeft: newSizeLeft,
- sizeRight: newSizeRight,
- desc: newDesc,
- leftDesc: newFirstDesc,
- rightDesc: newLastDesc,
- scan: -1,
- free: -1,
- };
-
- commandHeap.push(obj);
-}
-
-function newCopy(left, right, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const newSizeLeft = heap[left + SIZE_SLOT];
- const newSizeRight = heap[right + SIZE_SLOT];
- const desc = `Copying node ${left} to ${right}`;
- newCommand(
- COMMAND.COPY,
- toSpace,
- fromSpace,
- left,
- right,
- newSizeLeft,
- newSizeRight,
- heap,
- desc,
- 'index',
- 'free',
- );
-}
-
-function endFlip(left, heap): void {
- const { length } = commandHeap;
- const fromSpace = commandHeap[length - 1].from;
- const toSpace = commandHeap[length - 1].to;
- const newSizeLeft = heap[left + SIZE_SLOT];
- const desc = 'Flip finished';
- newCommand(
- COMMAND.FLIP,
- toSpace,
- fromSpace,
- left,
- -1,
- newSizeLeft,
- 0,
- heap,
- desc,
- 'free',
- '',
- );
- updateFlip();
-}
-
-function updateRoots(array): void {
- for (let i = 0; i < array.length; i += 1) {
- ROOTS.push(array[i]);
- }
-}
-
-function resetRoots(): void {
- ROOTS = [];
-}
-
-function startFlip(toSpace, fromSpace, heap): void {
- const desc = 'Memory is exhausted. Start stop and copy garbage collector.';
- newCommand(
- 'Start of Cheneys',
- toSpace,
- fromSpace,
- -1,
- -1,
- 0,
- 0,
- heap,
- desc,
- '',
- '',
- );
- updateFlip();
-}
-
-function newPush(left, right, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const desc = `Push OS update memory ${left} and ${right}.`;
- newCommand(
- COMMAND.PUSH,
- toSpace,
- fromSpace,
- left,
- right,
- 1,
- 1,
- heap,
- desc,
- 'last child address slot',
- 'new child pushed',
- );
-}
-
-function newPop(res, left, right, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const newRes = res;
- const desc = `Pop OS from memory ${left}, with value ${newRes}.`;
- newCommand(
- COMMAND.POP,
- toSpace,
- fromSpace,
- left,
- right,
- 1,
- 1,
- heap,
- desc,
- 'popped memory',
- 'last child address slot',
- );
-}
-
-function doneShowRoot(heap): void {
- const toSpace = 0;
- const fromSpace = 0;
- const desc = 'All root nodes are copied';
- newCommand(
- 'Copied Roots',
- toSpace,
- fromSpace,
- -1,
- -1,
- 0,
- 0,
- heap,
- desc,
- '',
- '',
- );
-}
-
-function showRoots(left, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const newSizeLeft = heap[left + SIZE_SLOT];
- const desc = `Roots: node ${left}`;
- newCommand(
- 'Showing Roots',
- toSpace,
- fromSpace,
- left,
- -1,
- newSizeLeft,
- 0,
- heap,
- desc,
- 'roots',
- '',
- );
-}
-
-function newAssign(res, left, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const newRes = res;
- const desc = `Assign memory [${left}] with ${newRes}.`;
- newCommand(
- COMMAND.ASSIGN,
- toSpace,
- fromSpace,
- left,
- -1,
- 1,
- 1,
- heap,
- desc,
- 'assigned memory',
- '',
- );
-}
-
-function newNew(left, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- const newSizeLeft = heap[left + SIZE_SLOT];
- const desc = `New node starts in [${left}].`;
- newCommand(
- COMMAND.NEW,
- toSpace,
- fromSpace,
- left,
- -1,
- newSizeLeft,
- 0,
- heap,
- desc,
- 'new memory allocated',
- '',
- );
-}
-
-function scanFlip(left, right, scan, free, heap): void {
- const { length } = commandHeap;
- const toSpace = commandHeap[length - 1].to;
- const fromSpace = commandHeap[length - 1].from;
- memory = [];
- for (let j = 0; j < heap.length; j += 1) {
- memory.push(heap[j]);
- }
-
- const newLeft = left;
- const newRight = right;
- const newScan = scan;
- const newFree = free;
- let newDesc = `Scanning node at ${left} for children node ${scan} and ${free}`;
- if (scan) {
- if (free) {
- newDesc = `Scanning node at ${left} for children node ${scan} and ${free}`;
- } else {
- newDesc = `Scanning node at ${left} for children node ${scan}`;
- }
- } else if (free) {
- newDesc = `Scanning node at ${left} for children node ${free}`;
- }
-
- const obj: CommandHeapObject = {
- type: COMMAND.SCAN,
- to: toSpace,
- from: fromSpace,
- heap: memory,
- left: newLeft,
- right: newRight,
- sizeLeft: 1,
- sizeRight: 1,
- scan: newScan,
- free: newFree,
- desc: newDesc,
- leftDesc: 'scan',
- rightDesc: 'free',
- };
-
- commandHeap.push(obj);
-}
-
-function updateSlotSegment(
- tag: number,
- size: number,
- first: number,
- last: number,
-): void {
- if (tag >= 0) {
- TAG_SLOT = tag;
- }
- if (size >= 0) {
- SIZE_SLOT = size;
- }
- if (first >= 0) {
- FIRST_CHILD_SLOT = first;
- }
- if (last >= 0) {
- LAST_CHILD_SLOT = last;
- }
-}
-
-function get_memory_size(): number {
- return MEMORY_SIZE;
-}
-
-function get_tags(): Tag[] {
- return tags;
-}
-
-function get_command(): CommandHeapObject[] {
- return commandHeap;
-}
-
-function get_flips(): number[] {
- return flips;
-}
-
-function get_types(): String[] {
- return typeTag;
-}
-
-function get_from_space(): number {
- return FROM_SPACE;
-}
-
-function get_memory_heap(): MemoryHeaps {
- return memoryHeaps;
-}
-
-function get_to_memory_matrix(): MemoryHeaps {
- return toMemoryMatrix;
-}
-
-function get_from_memory_matrix(): MemoryHeaps {
- return fromMemoryMatrix;
-}
-
-function get_roots(): number[] {
- return ROOTS;
-}
-
-function get_slots(): number[] {
- return [TAG_SLOT, SIZE_SLOT, FIRST_CHILD_SLOT, LAST_CHILD_SLOT];
-}
-
-function get_to_space(): number {
- return TO_SPACE;
-}
-
-function get_column_size(): number {
- return COLUMN;
-}
-
-function get_row_size(): number {
- return ROW;
-}
-
-function init() {
- return {
- toReplString: () => '',
- get_memory_size,
- get_from_space,
- get_to_space,
- get_memory_heap,
- get_tags,
- get_types,
- get_column_size,
- get_row_size,
- get_from_memory_matrix,
- get_to_memory_matrix,
- get_flips,
- get_slots,
- get_command,
- get_roots,
- };
-}
-
-export {
- init,
- // initialisation
- initialize_memory,
- initialize_tag,
- generateMemory,
- allHeap,
- updateSlotSegment,
- resetFromSpace,
- newCommand,
- newCopy,
- endFlip,
- newPush,
- newPop,
- newAssign,
- newNew,
- scanFlip,
- startFlip,
- updateRoots,
- resetRoots,
- showRoots,
- doneShowRoot,
-};
+import { COMMAND, type CommandHeapObject, type Memory, type MemoryHeaps, type Tag } from './types';
+
+// Global Variables
+let ROW: number = 10;
+const COLUMN: number = 32;
+let MEMORY_SIZE: number = -99;
+let TO_SPACE: number;
+let FROM_SPACE: number;
+let memory: Memory;
+let memoryHeaps: Memory[] = [];
+const commandHeap: CommandHeapObject[] = [];
+let toMemoryMatrix: number[][];
+let fromMemoryMatrix: number[][];
+let tags: Tag[];
+let typeTag: string[];
+const flips: number[] = [];
+let TAG_SLOT: number = 0;
+let SIZE_SLOT: number = 1;
+let FIRST_CHILD_SLOT: number = 2;
+let LAST_CHILD_SLOT: number = 3;
+let ROOTS: number[] = [];
+
+function initialize_tag(allTag: number[], types: string[]): void {
+ tags = allTag;
+ typeTag = types;
+}
+
+function allHeap(newHeap: number[][]): void {
+ memoryHeaps = newHeap;
+}
+
+function updateFlip(): void {
+ flips.push(commandHeap.length - 1);
+}
+
+function generateMemory(): void {
+ toMemoryMatrix = [];
+ for (let i = 0; i < ROW / 2; i += 1) {
+ memory = [];
+ for (let j = 0; j < COLUMN && i * COLUMN + j < MEMORY_SIZE / 2; j += 1) {
+ memory.push(i * COLUMN + j);
+ }
+ toMemoryMatrix.push(memory);
+ }
+
+ fromMemoryMatrix = [];
+ for (let i = ROW / 2; i < ROW; i += 1) {
+ memory = [];
+ for (let j = 0; j < COLUMN && i * COLUMN + j < MEMORY_SIZE; j += 1) {
+ memory.push(i * COLUMN + j);
+ }
+ fromMemoryMatrix.push(memory);
+ }
+
+ const obj: CommandHeapObject = {
+ type: COMMAND.INIT,
+ to: TO_SPACE,
+ from: FROM_SPACE,
+ heap: [],
+ left: -1,
+ right: -1,
+ sizeLeft: 0,
+ sizeRight: 0,
+ desc: 'Memory initially empty.',
+ leftDesc: '',
+ rightDesc: '',
+ scan: -1,
+ free: -1,
+ };
+
+ commandHeap.push(obj);
+}
+
+function resetFromSpace(fromSpace, heap): number[] {
+ const newHeap: number[] = [];
+ if (fromSpace > 0) {
+ for (let i = 0; i < MEMORY_SIZE / 2; i += 1) {
+ newHeap.push(heap[i]);
+ }
+ for (let i = MEMORY_SIZE / 2; i < MEMORY_SIZE; i += 1) {
+ newHeap.push(0);
+ }
+ } else {
+ // to space between 0...M/2
+ for (let i = 0; i < MEMORY_SIZE / 2; i += 1) {
+ newHeap.push(0);
+ }
+ for (let i = MEMORY_SIZE / 2; i < MEMORY_SIZE; i += 1) {
+ newHeap.push(heap[i]);
+ }
+ }
+ return newHeap;
+}
+
+function initialize_memory(memorySize: number): void {
+ MEMORY_SIZE = memorySize;
+ ROW = MEMORY_SIZE / COLUMN;
+ TO_SPACE = 0;
+ FROM_SPACE = MEMORY_SIZE / 2;
+ generateMemory();
+}
+
+function newCommand(
+ type,
+ toSpace,
+ fromSpace,
+ left,
+ right,
+ sizeLeft,
+ sizeRight,
+ heap,
+ description,
+ firstDesc,
+ lastDesc,
+): void {
+ const newType = type;
+ const newToSpace = toSpace;
+ const newFromSpace = fromSpace;
+ const newLeft = left;
+ const newRight = right;
+ const newSizeLeft = sizeLeft;
+ const newSizeRight = sizeRight;
+ const newDesc = description;
+ const newFirstDesc = firstDesc;
+ const newLastDesc = lastDesc;
+
+ memory = [];
+ for (let j = 0; j < heap.length; j += 1) {
+ memory.push(heap[j]);
+ }
+
+ const obj: CommandHeapObject = {
+ type: newType,
+ to: newToSpace,
+ from: newFromSpace,
+ heap: memory,
+ left: newLeft,
+ right: newRight,
+ sizeLeft: newSizeLeft,
+ sizeRight: newSizeRight,
+ desc: newDesc,
+ leftDesc: newFirstDesc,
+ rightDesc: newLastDesc,
+ scan: -1,
+ free: -1,
+ };
+
+ commandHeap.push(obj);
+}
+
+function newCopy(left, right, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const newSizeLeft = heap[left + SIZE_SLOT];
+ const newSizeRight = heap[right + SIZE_SLOT];
+ const desc = `Copying node ${left} to ${right}`;
+ newCommand(
+ COMMAND.COPY,
+ toSpace,
+ fromSpace,
+ left,
+ right,
+ newSizeLeft,
+ newSizeRight,
+ heap,
+ desc,
+ 'index',
+ 'free',
+ );
+}
+
+function endFlip(left, heap): void {
+ const { length } = commandHeap;
+ const fromSpace = commandHeap[length - 1].from;
+ const toSpace = commandHeap[length - 1].to;
+ const newSizeLeft = heap[left + SIZE_SLOT];
+ const desc = 'Flip finished';
+ newCommand(
+ COMMAND.FLIP,
+ toSpace,
+ fromSpace,
+ left,
+ -1,
+ newSizeLeft,
+ 0,
+ heap,
+ desc,
+ 'free',
+ '',
+ );
+ updateFlip();
+}
+
+function updateRoots(array): void {
+ for (let i = 0; i < array.length; i += 1) {
+ ROOTS.push(array[i]);
+ }
+}
+
+function resetRoots(): void {
+ ROOTS = [];
+}
+
+function startFlip(toSpace, fromSpace, heap): void {
+ const desc = 'Memory is exhausted. Start stop and copy garbage collector.';
+ newCommand(
+ 'Start of Cheneys',
+ toSpace,
+ fromSpace,
+ -1,
+ -1,
+ 0,
+ 0,
+ heap,
+ desc,
+ '',
+ '',
+ );
+ updateFlip();
+}
+
+function newPush(left, right, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const desc = `Push OS update memory ${left} and ${right}.`;
+ newCommand(
+ COMMAND.PUSH,
+ toSpace,
+ fromSpace,
+ left,
+ right,
+ 1,
+ 1,
+ heap,
+ desc,
+ 'last child address slot',
+ 'new child pushed',
+ );
+}
+
+function newPop(res, left, right, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const newRes = res;
+ const desc = `Pop OS from memory ${left}, with value ${newRes}.`;
+ newCommand(
+ COMMAND.POP,
+ toSpace,
+ fromSpace,
+ left,
+ right,
+ 1,
+ 1,
+ heap,
+ desc,
+ 'popped memory',
+ 'last child address slot',
+ );
+}
+
+function doneShowRoot(heap): void {
+ const toSpace = 0;
+ const fromSpace = 0;
+ const desc = 'All root nodes are copied';
+ newCommand(
+ 'Copied Roots',
+ toSpace,
+ fromSpace,
+ -1,
+ -1,
+ 0,
+ 0,
+ heap,
+ desc,
+ '',
+ '',
+ );
+}
+
+function showRoots(left, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const newSizeLeft = heap[left + SIZE_SLOT];
+ const desc = `Roots: node ${left}`;
+ newCommand(
+ 'Showing Roots',
+ toSpace,
+ fromSpace,
+ left,
+ -1,
+ newSizeLeft,
+ 0,
+ heap,
+ desc,
+ 'roots',
+ '',
+ );
+}
+
+function newAssign(res, left, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const newRes = res;
+ const desc = `Assign memory [${left}] with ${newRes}.`;
+ newCommand(
+ COMMAND.ASSIGN,
+ toSpace,
+ fromSpace,
+ left,
+ -1,
+ 1,
+ 1,
+ heap,
+ desc,
+ 'assigned memory',
+ '',
+ );
+}
+
+function newNew(left, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ const newSizeLeft = heap[left + SIZE_SLOT];
+ const desc = `New node starts in [${left}].`;
+ newCommand(
+ COMMAND.NEW,
+ toSpace,
+ fromSpace,
+ left,
+ -1,
+ newSizeLeft,
+ 0,
+ heap,
+ desc,
+ 'new memory allocated',
+ '',
+ );
+}
+
+function scanFlip(left, right, scan, free, heap): void {
+ const { length } = commandHeap;
+ const toSpace = commandHeap[length - 1].to;
+ const fromSpace = commandHeap[length - 1].from;
+ memory = [];
+ for (let j = 0; j < heap.length; j += 1) {
+ memory.push(heap[j]);
+ }
+
+ const newLeft = left;
+ const newRight = right;
+ const newScan = scan;
+ const newFree = free;
+ let newDesc = `Scanning node at ${left} for children node ${scan} and ${free}`;
+ if (scan) {
+ if (free) {
+ newDesc = `Scanning node at ${left} for children node ${scan} and ${free}`;
+ } else {
+ newDesc = `Scanning node at ${left} for children node ${scan}`;
+ }
+ } else if (free) {
+ newDesc = `Scanning node at ${left} for children node ${free}`;
+ }
+
+ const obj: CommandHeapObject = {
+ type: COMMAND.SCAN,
+ to: toSpace,
+ from: fromSpace,
+ heap: memory,
+ left: newLeft,
+ right: newRight,
+ sizeLeft: 1,
+ sizeRight: 1,
+ scan: newScan,
+ free: newFree,
+ desc: newDesc,
+ leftDesc: 'scan',
+ rightDesc: 'free',
+ };
+
+ commandHeap.push(obj);
+}
+
+function updateSlotSegment(
+ tag: number,
+ size: number,
+ first: number,
+ last: number,
+): void {
+ if (tag >= 0) {
+ TAG_SLOT = tag;
+ }
+ if (size >= 0) {
+ SIZE_SLOT = size;
+ }
+ if (first >= 0) {
+ FIRST_CHILD_SLOT = first;
+ }
+ if (last >= 0) {
+ LAST_CHILD_SLOT = last;
+ }
+}
+
+function get_memory_size(): number {
+ return MEMORY_SIZE;
+}
+
+function get_tags(): Tag[] {
+ return tags;
+}
+
+function get_command(): CommandHeapObject[] {
+ return commandHeap;
+}
+
+function get_flips(): number[] {
+ return flips;
+}
+
+function get_types(): String[] {
+ return typeTag;
+}
+
+function get_from_space(): number {
+ return FROM_SPACE;
+}
+
+function get_memory_heap(): MemoryHeaps {
+ return memoryHeaps;
+}
+
+function get_to_memory_matrix(): MemoryHeaps {
+ return toMemoryMatrix;
+}
+
+function get_from_memory_matrix(): MemoryHeaps {
+ return fromMemoryMatrix;
+}
+
+function get_roots(): number[] {
+ return ROOTS;
+}
+
+function get_slots(): number[] {
+ return [TAG_SLOT, SIZE_SLOT, FIRST_CHILD_SLOT, LAST_CHILD_SLOT];
+}
+
+function get_to_space(): number {
+ return TO_SPACE;
+}
+
+function get_column_size(): number {
+ return COLUMN;
+}
+
+function get_row_size(): number {
+ return ROW;
+}
+
+function init() {
+ return {
+ toReplString: () => '',
+ get_memory_size,
+ get_from_space,
+ get_to_space,
+ get_memory_heap,
+ get_tags,
+ get_types,
+ get_column_size,
+ get_row_size,
+ get_from_memory_matrix,
+ get_to_memory_matrix,
+ get_flips,
+ get_slots,
+ get_command,
+ get_roots,
+ };
+}
+
+export {
+ init,
+ // initialisation
+ initialize_memory,
+ initialize_tag,
+ generateMemory,
+ allHeap,
+ updateSlotSegment,
+ resetFromSpace,
+ newCommand,
+ newCopy,
+ endFlip,
+ newPush,
+ newPop,
+ newAssign,
+ newNew,
+ scanFlip,
+ startFlip,
+ updateRoots,
+ resetRoots,
+ showRoots,
+ doneShowRoot,
+};
diff --git a/src/bundles/copy_gc/types.ts b/src/bundles/copy_gc/types.ts
index 61caf5942..32ae5b243 100644
--- a/src/bundles/copy_gc/types.ts
+++ b/src/bundles/copy_gc/types.ts
@@ -1,32 +1,32 @@
-export type Memory = number[];
-export type MemoryHeaps = Memory[];
-export type Tag = number;
-
-// command type
-
-export enum COMMAND {
- FLIP = 'Flip',
- PUSH = 'Push',
- POP = 'Pop',
- COPY = 'Copy',
- ASSIGN = 'Assign',
- NEW = 'New',
- SCAN = 'Scan',
- INIT = 'Initialize Memory',
-}
-
-export type CommandHeapObject = {
- type: String;
- to: number;
- from: number;
- heap: number[];
- left: number;
- right: number;
- sizeLeft: number;
- sizeRight: number;
- desc: String;
- scan: number;
- leftDesc: String;
- rightDesc: String;
- free: number;
-};
+export type Memory = number[];
+export type MemoryHeaps = Memory[];
+export type Tag = number;
+
+// command type
+
+export enum COMMAND {
+ FLIP = 'Flip',
+ PUSH = 'Push',
+ POP = 'Pop',
+ COPY = 'Copy',
+ ASSIGN = 'Assign',
+ NEW = 'New',
+ SCAN = 'Scan',
+ INIT = 'Initialize Memory',
+}
+
+export type CommandHeapObject = {
+ type: String;
+ to: number;
+ from: number;
+ heap: number[];
+ left: number;
+ right: number;
+ sizeLeft: number;
+ sizeRight: number;
+ desc: String;
+ scan: number;
+ leftDesc: String;
+ rightDesc: String;
+ free: number;
+};
diff --git a/src/bundles/csg/constants.ts b/src/bundles/csg/constants.ts
index ba56bb87a..3616684e5 100644
--- a/src/bundles/csg/constants.ts
+++ b/src/bundles/csg/constants.ts
@@ -1,20 +1,20 @@
-/* [Exports] */
-
-// Renderer default colour. Bright aquamarine makes bugs easier to spot
-export const DEFAULT_COLOR = '#55ffaa';
-
-// Renderer grid constants
-export const MAIN_TICKS: number = 1;
-export const SUB_TICKS: number = MAIN_TICKS / 4;
-export const GRID_PADDING: number = MAIN_TICKS;
-export const ROUND_UP_INTERVAL: number = MAIN_TICKS;
-
-// Controls zoom constants
-export const ZOOM_TICK_SCALE: number = 0.1;
-
-// Controls rotation constants
-export const ROTATION_SPEED: number = 0.0015;
-
-// Controls pan constants
-export const X_FACTOR: number = 1;
-export const Y_FACTOR: number = 0.75;
+/* [Exports] */
+
+// Renderer default colour. Bright aquamarine makes bugs easier to spot
+export const DEFAULT_COLOR = '#55ffaa';
+
+// Renderer grid constants
+export const MAIN_TICKS: number = 1;
+export const SUB_TICKS: number = MAIN_TICKS / 4;
+export const GRID_PADDING: number = MAIN_TICKS;
+export const ROUND_UP_INTERVAL: number = MAIN_TICKS;
+
+// Controls zoom constants
+export const ZOOM_TICK_SCALE: number = 0.1;
+
+// Controls rotation constants
+export const ROTATION_SPEED: number = 0.0015;
+
+// Controls pan constants
+export const X_FACTOR: number = 1;
+export const Y_FACTOR: number = 0.75;
diff --git a/src/bundles/csg/core.ts b/src/bundles/csg/core.ts
index d55d80630..82bef31e4 100644
--- a/src/bundles/csg/core.ts
+++ b/src/bundles/csg/core.ts
@@ -1,25 +1,25 @@
-/* [Imports] */
-import type { CsgModuleState, RenderGroupManager } from './utilities.js';
-
-/* [Exports] */
-// After bundle initialises, tab will need to re-init on its end, as they run
-// independently and are different versions of Core
-export class Core {
- private static moduleState: CsgModuleState | null = null;
-
- public static initialize(csgModuleState: CsgModuleState): void {
- Core.moduleState = csgModuleState;
- }
-
- public static getRenderGroupManager(): RenderGroupManager {
- let moduleState: CsgModuleState = Core.moduleState as CsgModuleState;
-
- return moduleState.renderGroupManager;
- }
-
- public static nextComponent(): number {
- let moduleState: CsgModuleState = Core.moduleState as CsgModuleState;
-
- return moduleState.nextComponent();
- }
-}
+/* [Imports] */
+import type { CsgModuleState, RenderGroupManager } from './utilities.js';
+
+/* [Exports] */
+// After bundle initialises, tab will need to re-init on its end, as they run
+// independently and are different versions of Core
+export class Core {
+ private static moduleState: CsgModuleState | null = null;
+
+ public static initialize(csgModuleState: CsgModuleState): void {
+ Core.moduleState = csgModuleState;
+ }
+
+ public static getRenderGroupManager(): RenderGroupManager {
+ let moduleState: CsgModuleState = Core.moduleState as CsgModuleState;
+
+ return moduleState.renderGroupManager;
+ }
+
+ public static nextComponent(): number {
+ let moduleState: CsgModuleState = Core.moduleState as CsgModuleState;
+
+ return moduleState.nextComponent();
+ }
+}
diff --git a/src/bundles/csg/functions.ts b/src/bundles/csg/functions.ts
index 277409534..911af36dd 100644
--- a/src/bundles/csg/functions.ts
+++ b/src/bundles/csg/functions.ts
@@ -1,886 +1,827 @@
-/**
- * The CSG module enables working with Constructive Solid Geometry in the Source
- * Academy. Users are able to program colored 3D models and interact with them
- * in a tab.
- *
- * The main objects in use are called Shapes. Users can create, operate on,
- * transform, and finally render these Shapes.
- *
- * There are also Groups, which contain Shapes, but can also contain other
- * nested Groups. Groups allow many Shapes to be transformed in tandem, as
- * opposed to having to call transform functions on each Shape individually.
- *
- * An object that is either a Shape or a Group is called an Operable. Operables
- * as a whole are stateless, which means that passing them into functions does
- * not modify the original Operable; instead, the newly created Operable is
- * returned. Therefore, it is safe to reuse existing Operables after passing
- * them into functions, as they remain immutable.
- *
- * When you are done modeling your Operables, pass them to one of the CSG
- * rendering functions to have them displayed in a tab.
- *
- * When rendering, you may optionally render with a grid and/or axes displayed,
- * depending on the rendering function used. The grid appears on the XY-plane
- * with white lines every 1 unit of distance, and slightly fainter lines every
- * 0.25 units of distance. The axes for x, y, and z are coloured red, green, and
- * blue respectively. The positive z direction is upwards from the flat plane
- * (right-handed coordinate system).
- *
- * ```js
- * // Sample usage
- * import {
- * silver, crimson, cyan,
- * cube, cone, sphere,
- * intersect, union, scale, translate,
- * render_grid_axes
- * } from "csg";
- *
- * const base = intersect(
- * scale(cube(silver), 1, 1, 0.3),
- * scale(cone(crimson), 1, 1, 3)
- * );
- * const snowglobe = union(
- * translate(sphere(cyan), 0, 0, 0.22),
- * base
- * );
- * render_grid_axes(snowglobe);
- * ```
- *
- * More samples can be found at: https://github.com/source-academy/modules/tree/master/src/bundles/csg/samples
- *
- * @module csg
- * @author Joel Leow
- * @author Liu Muchen
- * @author Ng Yin Joe
- * @author Yu Chenbo
- */
-
-
-
-/* [Imports] */
-import { primitives } from '@jscad/modeling';
-import { colorize as colorSolid } from '@jscad/modeling/src/colors';
-import {
- measureBoundingBox,
- type BoundingBox,
-} from '@jscad/modeling/src/measurements';
-import {
- intersect as _intersect,
- subtract as _subtract,
- union as _union,
-} from '@jscad/modeling/src/operations/booleans';
-import { extrudeLinear } from '@jscad/modeling/src/operations/extrusions';
-import { serialize } from '@jscad/stl-serializer';
-import {
- head,
- list,
- tail,
- type List,
- is_list,
-} from 'js-slang/dist/stdlib/list';
-import save from 'save-file';
-import { Core } from './core.js';
-import type { Solid } from './jscad/types.js';
-import {
- Group,
- Shape,
- hexToColor,
- type Operable,
- type RenderGroup,
- centerPrimitive,
-} from './utilities';
-import { degreesToRadians } from '../../common/utilities.js';
-
-
-
-/* [Main] */
-/* NOTE
- These functions involving calls (not merely types) to js-slang make this file
- only usable in bundles. DO NOT import this file in tabs or the build will
- fail. Something about the node modules that building them involves causes
- esbuild to attempt but fail to include Node-specific APIs (eg fs, os, https)
- in the output that's meant for a browser environment (you can't use those in
- the browser since they are Node-only). This is why we keep these functions
- here instead of in utilities.ts.
-
- When a user passes in a List, we convert it to arrays here so that the rest of
- the underlying code is free to operate with arrays.
-*/
-export function listToArray(l: List): Operable[] {
- let operables: Operable[] = [];
- while (l !== null) {
- let operable: Operable = head(l);
- operables.push(operable);
- l = tail(l);
- }
- return operables;
-}
-
-export function arrayToList(array: Operable[]): List {
- return list(...array);
-}
-
-
-
-/* [Exports] */
-
-// [Variables - Colors]
-
-/**
- * A hex color code for black (#000000).
- *
- * @category Colors
- */
-export const black: string = '#000000';
-
-/**
- * A hex color code for dark blue (#0000AA).
- *
- * @category Colors
- */
-export const navy: string = '#0000AA';
-
-/**
- * A hex color code for green (#00AA00).
- *
- * @category Colors
- */
-export const green: string = '#00AA00';
-
-/**
- * A hex color code for dark cyan (#00AAAA).
- *
- * @category Colors
- */
-export const teal: string = '#00AAAA';
-
-/**
- * A hex color code for dark red (#AA0000).
- *
- * @category Colors
- */
-export const crimson: string = '#AA0000';
-
-/**
- * A hex color code for purple (#AA00AA).
- *
- * @category Colors
- */
-export const purple: string = '#AA00AA';
-
-/**
- * A hex color code for orange (#FFAA00).
- *
- * @category Colors
- */
-export const orange: string = '#FFAA00';
-
-/**
- * A hex color code for light gray (#AAAAAA).
- *
- * @category Colors
- */
-export const silver: string = '#AAAAAA';
-
-/**
- * A hex color code for dark gray (#555555).
- *
- * @category Colors
- */
-export const gray: string = '#555555';
-
-/**
- * A hex color code for blue (#5555FF).
- *
- * @category Colors
- */
-export const blue: string = '#5555FF';
-
-/**
- * A hex color code for light green (#55FF55).
- *
- * @category Colors
- */
-export const lime: string = '#55FF55';
-
-/**
- * A hex color code for cyan (#55FFFF).
- *
- * @category Colors
- */
-export const cyan: string = '#55FFFF';
-
-/**
- * A hex color code for light red (#FF5555).
- *
- * @category Colors
- */
-export const rose: string = '#FF5555';
-
-/**
- * A hex color code for pink (#FF55FF).
- *
- * @category Colors
- */
-export const pink: string = '#FF55FF';
-
-/**
- * A hex color code for yellow (#FFFF55).
- *
- * @category Colors
- */
-export const yellow: string = '#FFFF55';
-
-/**
- * A hex color code for white (#FFFFFF).
- *
- * @category Colors
- */
-export const white: string = '#FFFFFF';
-
-// [Functions - Primitives]
-
-/**
- * Returns a cube Shape in the specified color.
- *
- * - Side length: 1
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function cube(hex: string): Shape {
- let solid: Solid = primitives.cube({ size: 1 });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a rounded cube Shape in the specified color.
- *
- * - Side length: 1
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function rounded_cube(hex: string): Shape {
- let solid: Solid = primitives.roundedCuboid({ size: [1, 1, 1] });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns an upright cylinder Shape in the specified color.
- *
- * - Height: 1
- * - Radius: 0.5
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function cylinder(hex: string): Shape {
- let solid: Solid = primitives.cylinder({
- height: 1,
- radius: 0.5,
- });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a rounded, upright cylinder Shape in the specified color.
- *
- * - Height: 1
- * - Radius: 0.5
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function rounded_cylinder(hex: string): Shape {
- let solid: Solid = primitives.roundedCylinder({
- height: 1,
- radius: 0.5,
- });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a sphere Shape in the specified color.
- *
- * - Radius: 0.5
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function sphere(hex: string): Shape {
- let solid: Solid = primitives.sphere({ radius: 0.5 });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a geodesic sphere Shape in the specified color.
- *
- * - Radius: 0.5
- * - Center: Floating at (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function geodesic_sphere(hex: string): Shape {
- let solid: Solid = primitives.geodesicSphere({ radius: 0.5 });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a square pyramid Shape in the specified color.
- *
- * - Height: 1
- * - Base length: 1
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function pyramid(hex: string): Shape {
- let pythagorasSide: number = Math.sqrt(2); // sqrt(1^2 + 1^2)
- let radius = pythagorasSide / 2;
- let solid: Solid = primitives.cylinderElliptic({
- height: 1,
- // Base starting radius
- startRadius: [radius, radius],
- // Radius by the time the top is reached
- endRadius: [0, 0],
- segments: 4,
- });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- shape = rotate(shape, 0, 0, degreesToRadians(45)) as Shape;
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a cone Shape in the specified color.
- *
- * - Height: 1
- * - Radius: 0.5
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function cone(hex: string): Shape {
- let solid: Solid = primitives.cylinderElliptic({
- height: 1,
- startRadius: [0.5, 0.5],
- endRadius: [0, 0],
- });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns an upright triangular prism Shape in the specified color.
- *
- * - Height: 1
- * - Side length: 1
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function prism(hex: string): Shape {
- let solid: Solid = extrudeLinear(
- { height: 1 },
- primitives.triangle(),
- );
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- shape = rotate(shape, 0, 0, degreesToRadians(-90)) as Shape;
- return centerPrimitive(shape);
-}
-
-/**
- * Returns an upright extruded star Shape in the specified color.
- *
- * - Height: 1
- * - Center: (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function star(hex: string): Shape {
- let solid: Solid = extrudeLinear(
- { height: 1 },
- primitives.star({ outerRadius: 0.5 }),
- );
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-/**
- * Returns a torus (donut) Shape in the specified color.
- *
- * - Inner radius: 0.15 (ring is 0.3 thick)
- * - Total radius: 0.5 (from the centre of the hole to "outside")
- * - Center: Floating at (0.5, 0.5, 0.5)
- *
- * @param hex hex color code
- *
- * @category Primitives
- */
-export function torus(hex: string): Shape {
- let solid: Solid = primitives.torus({
- innerRadius: 0.15,
- outerRadius: 0.35,
- });
- let shape: Shape = new Shape(
- colorSolid(
- hexToColor(hex),
- solid,
- ),
- );
- return centerPrimitive(shape);
-}
-
-// [Functions - Operations]
-
-/**
- * Returns the union of the two specified Shapes.
- *
- * @param first first Shape
- * @param second second Shape
- * @returns unioned Shape
- *
- * @category Operations
- */
-export function union(first: Shape, second: Shape): Shape {
- if (!is_shape(first) || !is_shape(second)) {
- throw new Error('Failed to union, only Shapes can be operated on');
- }
-
- let solid: Solid = _union(first.solid, second.solid);
- return new Shape(solid);
-}
-
-/**
- * Subtracts the second Shape from the first Shape, returning the resultant
- * Shape.
- *
- * @param target target Shape to be subtracted from
- * @param subtractedShape Shape to remove from the first Shape
- * @returns subtracted Shape
- *
- * @category Operations
- */
-export function subtract(target: Shape, subtractedShape: Shape): Shape {
- if (!is_shape(target) || !is_shape(subtractedShape)) {
- throw new Error('Failed to subtract, only Shapes can be operated on');
- }
-
- let solid: Solid = _subtract(target.solid, subtractedShape.solid);
- return new Shape(solid);
-}
-
-/**
- * Returns the intersection of the two specified Shapes.
- *
- * @param first first Shape
- * @param second second Shape
- * @returns intersected Shape
- *
- * @category Operations
- */
-export function intersect(first: Shape, second: Shape): Shape {
- if (!is_shape(first) || !is_shape(second)) {
- throw new Error('Failed to intersect, only Shapes can be operated on');
- }
-
- let solid: Solid = _intersect(first.solid, second.solid);
- return new Shape(solid);
-}
-
-// [Functions - Transformations]
-
-/**
- * Translates (moves) the specified Operable in the x, y, and z directions using
- * the specified offsets.
- *
- * @param operable Shape or Group
- * @param xOffset x offset
- * @param yOffset y offset
- * @param zOffset z offset
- * @returns translated Shape
- *
- * @category Transformations
- */
-export function translate(
- operable: Operable,
- xOffset: number,
- yOffset: number,
- zOffset: number,
-): Operable {
- return operable.translate([xOffset, yOffset, zOffset]);
-}
-
-/**
- * Sequentially rotates the specified Operable about the x, y, and z axes using
- * the specified angles, in radians (i.e. 2π represents 360°).
- *
- * The order of rotation is: x, y, then z axis. The order of rotation can affect
- * the result, so you may wish to make multiple separate calls to rotate() if
- * you require a specific order of rotation.
- *
- * @param operable Shape or Group
- * @param xAngle x angle in radians
- * @param yAngle y angle in radians
- * @param zAngle z angle in radians
- * @returns rotated Shape
- *
- * @category Transformations
- */
-export function rotate(
- operable: Operable,
- xAngle: number,
- yAngle: number,
- zAngle: number,
-): Operable {
- return operable.rotate([xAngle, yAngle, zAngle]);
-}
-
-/**
- * Scales the specified Operable in the x, y, and z directions using the
- * specified factors. Scaling is done about the origin (0, 0, 0).
- *
- * For example, a factor of 0.5 results in a smaller Shape, while a factor of 2
- * results in a larger Shape. A factor of 1 results in the original Shape.
- * Factors must be greater than 0.
- *
- * @param operable Shape or Group
- * @param xFactor x scaling factor
- * @param yFactor y scaling factor
- * @param zFactor z scaling factor
- * @returns scaled Shape
- *
- * @category Transformations
- */
-export function scale(
- operable: Operable,
- xFactor: number,
- yFactor: number,
- zFactor: number,
-): Operable {
- if (xFactor <= 0 || yFactor <= 0 || zFactor <= 0) {
- // JSCAD library does not allow factors <= 0
- throw new Error('Scaling factor must be greater than 0');
- }
-
- return operable.scale([xFactor, yFactor, zFactor]);
-}
-
-// [Functions - Utilities]
-
-/**
- * Groups the specified list of Operables together. Groups can contain a mix of
- * Shapes and other nested Groups.
- *
- * Groups cannot be operated on, but can be transformed together. I.e. a call
- * like `intersect(group_a, group_b)` is not allowed, but a call like
- * `scale(group, 5, 5, 5)` is.
- *
- * @param operables list of Shapes and/or Groups
- * @returns new Group
- *
- * @category Utilities
- */
-export function group(operables: List): Group {
- if (!is_list(operables)) {
- throw new Error('Only lists of Operables can be grouped');
- }
-
- return new Group(listToArray(operables));
-}
-
-/**
- * Ungroups the specified Group, returning the list of Shapes and/or nested
- * Groups contained within.
- *
- * @param g Group to ungroup
- * @returns ungrouped list of Shapes and/or Groups
- *
- * @category Utilities
- */
-export function ungroup(g: Group): List {
- if (!is_group(g)) {
- throw new Error('Only Groups can be ungrouped');
- }
-
- return arrayToList(g.ungroup());
-}
-
-/**
- * Checks if the given parameter is a Shape.
- *
- * @param parameter parameter to check
- * @returns whether parameter is a Shape
- *
- * @category Utilities
- */
-export function is_shape(parameter: unknown): boolean {
- return parameter instanceof Shape;
-}
-
-/**
- * Checks if the given parameter is a Group.
- *
- * @param parameter parameter to check
- * @returns whether parameter is a Group
- *
- * @category Utilities
- */
-export function is_group(parameter: unknown): boolean {
- return parameter instanceof Group;
-}
-
-/**
- * Returns a function of type (string, string) → number, for getting the
- * specified Shape's bounding box coordinates.
- *
- * Its first parameter must be "x", "y", or "z", indicating the coordinate axis.
- *
- * Its second parameter must be "min" or "max", indicating the minimum or
- * maximum bounding box coordinate respectively.
- *
- * For example, if a sphere of radius 0.5 is centred at (0.5, 0.5, 0.5), its
- * minimum bounding coordinates will be (0, 0, 0), and its maximum bounding
- * coordinates will be (1, 1, 1).
- *
- * ```js
- * // Sample usage
- * const getter_function = bounding_box(sphere(silver));
- * display(getter_function("y", "max")); // Displays 1, the maximum y coordinate
- * ```
- *
- * @param shape Shape to measure
- * @returns bounding box getter function
- *
- * @category Utilities
- */
-export function bounding_box(
- shape: Shape,
-): (axis: string, minMax: string) => number {
- let bounds: BoundingBox = measureBoundingBox(shape.solid);
-
- return (axis: string, minMax: string): number => {
- let j: number;
- if (axis === 'x') j = 0;
- else if (axis === 'y') j = 1;
- else if (axis === 'z') j = 2;
- else {
- throw new Error(
- `Bounding box getter function expected "x", "y", or "z" as first parameter, but got ${axis}`,
- );
- }
-
- let i: number;
- if (minMax === 'min') i = 0;
- else if (minMax === 'max') i = 1;
- else {
- throw new Error(
- `Bounding box getter function expected "min" or "max" as second parameter, but got ${minMax}`,
- );
- }
-
- return bounds[i][j];
- };
-}
-
-/**
- * Returns a hex color code representing the specified RGB values.
- *
- * @param redValue red value of the color
- * @param greenValue green value of the color
- * @param blueValue blue value of the color
- * @returns hex color code
- *
- * @category Utilities
- */
-export function rgb(
- redValue: number,
- greenValue: number,
- blueValue: number,
-): string {
- if (
- redValue < 0
- || redValue > 255
- || greenValue < 0
- || greenValue > 255
- || blueValue < 0
- || blueValue > 255
- ) {
- throw new Error('RGB values must be between 0 and 255 (inclusive)');
- }
-
- return `#${redValue.toString(16)}${greenValue.toString(16)}
- ${blueValue.toString(16)}`;
-}
-
-/**
- * Exports the specified Shape as an STL file, downloaded to your device.
- *
- * The file can be used for purposes such as 3D printing.
- *
- * @param shape Shape to export
- *
- * @category Utilities
- */
-export async function download_shape_stl(shape: Shape): Promise {
- if (!is_shape(shape)) {
- throw new Error('Failed to export, only Shapes can be converted to STL');
- }
-
- await save(
- new Blob(serialize({ binary: true }, shape.solid)),
- 'Source Academy CSG Shape.stl',
- );
-}
-
-// [Functions - Rendering]
-
-/**
- * Renders the specified Operable.
- *
- * @param operable Shape or Group to render
- *
- * @category Rendering
- */
-export function render(operable: Operable): RenderGroup {
- if (!(operable instanceof Shape || operable instanceof Group)) {
- throw new Error('Only Operables can be rendered');
- }
-
- operable.store();
-
- // Trigger a new render group for use with subsequent renders.
- // Render group is returned for REPL text only; do not document
- return Core.getRenderGroupManager()
- .nextRenderGroup();
-}
-
-/**
- * Renders the specified Operable, along with a grid.
- *
- * @param operable Shape or Group to render
- *
- * @category Rendering
- */
-export function render_grid(operable: Operable): RenderGroup {
- if (!(operable instanceof Shape || operable instanceof Group)) {
- throw new Error('Only Operables can be rendered');
- }
-
- operable.store();
-
- return Core.getRenderGroupManager()
- .nextRenderGroup(true);
-}
-
-/**
- * Renders the specified Operable, along with z, y, and z axes.
- *
- * @param operable Shape or Group to render
- *
- * @category Rendering
- */
-export function render_axes(operable: Operable): RenderGroup {
- if (!(operable instanceof Shape || operable instanceof Group)) {
- throw new Error('Only Operables can be rendered');
- }
-
- operable.store();
-
- return Core.getRenderGroupManager()
- .nextRenderGroup(undefined, true);
-}
-
-/**
- * Renders the specified Operable, along with both a grid and axes.
- *
- * @param operable Shape or Group to render
- *
- * @category Rendering
- */
-export function render_grid_axes(operable: Operable): RenderGroup {
- if (!(operable instanceof Shape || operable instanceof Group)) {
- throw new Error('Only Operables can be rendered');
- }
-
- operable.store();
-
- return Core.getRenderGroupManager()
- .nextRenderGroup(true, true);
-}
+/* [Imports] */
+import { primitives } from '@jscad/modeling';
+import { colorize as colorSolid } from '@jscad/modeling/src/colors';
+import {
+ measureBoundingBox,
+ type BoundingBox,
+} from '@jscad/modeling/src/measurements';
+import {
+ intersect as _intersect,
+ subtract as _subtract,
+ union as _union,
+} from '@jscad/modeling/src/operations/booleans';
+import { extrudeLinear } from '@jscad/modeling/src/operations/extrusions';
+import { serialize } from '@jscad/stl-serializer';
+import {
+ head,
+ list,
+ tail,
+ type List,
+ is_list,
+} from 'js-slang/dist/stdlib/list';
+import save from 'save-file';
+import { Core } from './core.js';
+import type { Solid } from './jscad/types.js';
+import {
+ Group,
+ Shape,
+ hexToColor,
+ type Operable,
+ type RenderGroup,
+ centerPrimitive,
+} from './utilities';
+import { degreesToRadians } from '../../common/utilities.js';
+
+
+
+/* [Main] */
+/* NOTE
+ These functions involving calls (not merely types) to js-slang make this file
+ only usable in bundles. DO NOT import this file in tabs or the build will
+ fail. Something about the node modules that building them involves causes
+ esbuild to attempt but fail to include Node-specific APIs (eg fs, os, https)
+ in the output that's meant for a browser environment (you can't use those in
+ the browser since they are Node-only). This is why we keep these functions
+ here instead of in utilities.ts.
+
+ When a user passes in a List, we convert it to arrays here so that the rest of
+ the underlying code is free to operate with arrays.
+*/
+export function listToArray(l: List): Operable[] {
+ let operables: Operable[] = [];
+ while (l !== null) {
+ let operable: Operable = head(l);
+ operables.push(operable);
+ l = tail(l);
+ }
+ return operables;
+}
+
+export function arrayToList(array: Operable[]): List {
+ return list(...array);
+}
+
+
+
+/* [Exports] */
+
+// [Variables - Colors]
+
+/**
+ * A hex color code for black (#000000).
+ *
+ * @category Colors
+ */
+export const black: string = '#000000';
+
+/**
+ * A hex color code for dark blue (#0000AA).
+ *
+ * @category Colors
+ */
+export const navy: string = '#0000AA';
+
+/**
+ * A hex color code for green (#00AA00).
+ *
+ * @category Colors
+ */
+export const green: string = '#00AA00';
+
+/**
+ * A hex color code for dark cyan (#00AAAA).
+ *
+ * @category Colors
+ */
+export const teal: string = '#00AAAA';
+
+/**
+ * A hex color code for dark red (#AA0000).
+ *
+ * @category Colors
+ */
+export const crimson: string = '#AA0000';
+
+/**
+ * A hex color code for purple (#AA00AA).
+ *
+ * @category Colors
+ */
+export const purple: string = '#AA00AA';
+
+/**
+ * A hex color code for orange (#FFAA00).
+ *
+ * @category Colors
+ */
+export const orange: string = '#FFAA00';
+
+/**
+ * A hex color code for light gray (#AAAAAA).
+ *
+ * @category Colors
+ */
+export const silver: string = '#AAAAAA';
+
+/**
+ * A hex color code for dark gray (#555555).
+ *
+ * @category Colors
+ */
+export const gray: string = '#555555';
+
+/**
+ * A hex color code for blue (#5555FF).
+ *
+ * @category Colors
+ */
+export const blue: string = '#5555FF';
+
+/**
+ * A hex color code for light green (#55FF55).
+ *
+ * @category Colors
+ */
+export const lime: string = '#55FF55';
+
+/**
+ * A hex color code for cyan (#55FFFF).
+ *
+ * @category Colors
+ */
+export const cyan: string = '#55FFFF';
+
+/**
+ * A hex color code for light red (#FF5555).
+ *
+ * @category Colors
+ */
+export const rose: string = '#FF5555';
+
+/**
+ * A hex color code for pink (#FF55FF).
+ *
+ * @category Colors
+ */
+export const pink: string = '#FF55FF';
+
+/**
+ * A hex color code for yellow (#FFFF55).
+ *
+ * @category Colors
+ */
+export const yellow: string = '#FFFF55';
+
+/**
+ * A hex color code for white (#FFFFFF).
+ *
+ * @category Colors
+ */
+export const white: string = '#FFFFFF';
+
+// [Functions - Primitives]
+
+/**
+ * Returns a cube Shape in the specified color.
+ *
+ * - Side length: 1
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function cube(hex: string): Shape {
+ let solid: Solid = primitives.cube({ size: 1 });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a rounded cube Shape in the specified color.
+ *
+ * - Side length: 1
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function rounded_cube(hex: string): Shape {
+ let solid: Solid = primitives.roundedCuboid({ size: [1, 1, 1] });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns an upright cylinder Shape in the specified color.
+ *
+ * - Height: 1
+ * - Radius: 0.5
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function cylinder(hex: string): Shape {
+ let solid: Solid = primitives.cylinder({
+ height: 1,
+ radius: 0.5,
+ });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a rounded, upright cylinder Shape in the specified color.
+ *
+ * - Height: 1
+ * - Radius: 0.5
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function rounded_cylinder(hex: string): Shape {
+ let solid: Solid = primitives.roundedCylinder({
+ height: 1,
+ radius: 0.5,
+ });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a sphere Shape in the specified color.
+ *
+ * - Radius: 0.5
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function sphere(hex: string): Shape {
+ let solid: Solid = primitives.sphere({ radius: 0.5 });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a geodesic sphere Shape in the specified color.
+ *
+ * - Radius: 0.5
+ * - Center: Floating at (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function geodesic_sphere(hex: string): Shape {
+ let solid: Solid = primitives.geodesicSphere({ radius: 0.5 });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a square pyramid Shape in the specified color.
+ *
+ * - Height: 1
+ * - Base length: 1
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function pyramid(hex: string): Shape {
+ let pythagorasSide: number = Math.sqrt(2); // sqrt(1^2 + 1^2)
+ let radius = pythagorasSide / 2;
+ let solid: Solid = primitives.cylinderElliptic({
+ height: 1,
+ // Base starting radius
+ startRadius: [radius, radius],
+ // Radius by the time the top is reached
+ endRadius: [0, 0],
+ segments: 4,
+ });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ shape = rotate(shape, 0, 0, degreesToRadians(45)) as Shape;
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a cone Shape in the specified color.
+ *
+ * - Height: 1
+ * - Radius: 0.5
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function cone(hex: string): Shape {
+ let solid: Solid = primitives.cylinderElliptic({
+ height: 1,
+ startRadius: [0.5, 0.5],
+ endRadius: [0, 0],
+ });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns an upright triangular prism Shape in the specified color.
+ *
+ * - Height: 1
+ * - Side length: 1
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function prism(hex: string): Shape {
+ let solid: Solid = extrudeLinear(
+ { height: 1 },
+ primitives.triangle(),
+ );
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ shape = rotate(shape, 0, 0, degreesToRadians(-90)) as Shape;
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns an upright extruded star Shape in the specified color.
+ *
+ * - Height: 1
+ * - Center: (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function star(hex: string): Shape {
+ let solid: Solid = extrudeLinear(
+ { height: 1 },
+ primitives.star({ outerRadius: 0.5 }),
+ );
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+/**
+ * Returns a torus (donut) Shape in the specified color.
+ *
+ * - Inner radius: 0.15 (ring is 0.3 thick)
+ * - Total radius: 0.5 (from the centre of the hole to "outside")
+ * - Center: Floating at (0.5, 0.5, 0.5)
+ *
+ * @param hex hex color code
+ *
+ * @category Primitives
+ */
+export function torus(hex: string): Shape {
+ let solid: Solid = primitives.torus({
+ innerRadius: 0.15,
+ outerRadius: 0.35,
+ });
+ let shape: Shape = new Shape(
+ colorSolid(
+ hexToColor(hex),
+ solid,
+ ),
+ );
+ return centerPrimitive(shape);
+}
+
+// [Functions - Operations]
+
+/**
+ * Returns the union of the two specified Shapes.
+ *
+ * @param first first Shape
+ * @param second second Shape
+ * @returns unioned Shape
+ *
+ * @category Operations
+ */
+export function union(first: Shape, second: Shape): Shape {
+ if (!is_shape(first) || !is_shape(second)) {
+ throw new Error('Failed to union, only Shapes can be operated on');
+ }
+
+ let solid: Solid = _union(first.solid, second.solid);
+ return new Shape(solid);
+}
+
+/**
+ * Subtracts the second Shape from the first Shape, returning the resultant
+ * Shape.
+ *
+ * @param target target Shape to be subtracted from
+ * @param subtractedShape Shape to remove from the first Shape
+ * @returns subtracted Shape
+ *
+ * @category Operations
+ */
+export function subtract(target: Shape, subtractedShape: Shape): Shape {
+ if (!is_shape(target) || !is_shape(subtractedShape)) {
+ throw new Error('Failed to subtract, only Shapes can be operated on');
+ }
+
+ let solid: Solid = _subtract(target.solid, subtractedShape.solid);
+ return new Shape(solid);
+}
+
+/**
+ * Returns the intersection of the two specified Shapes.
+ *
+ * @param first first Shape
+ * @param second second Shape
+ * @returns intersected Shape
+ *
+ * @category Operations
+ */
+export function intersect(first: Shape, second: Shape): Shape {
+ if (!is_shape(first) || !is_shape(second)) {
+ throw new Error('Failed to intersect, only Shapes can be operated on');
+ }
+
+ let solid: Solid = _intersect(first.solid, second.solid);
+ return new Shape(solid);
+}
+
+// [Functions - Transformations]
+
+/**
+ * Translates (moves) the specified Operable in the x, y, and z directions using
+ * the specified offsets.
+ *
+ * @param operable Shape or Group
+ * @param xOffset x offset
+ * @param yOffset y offset
+ * @param zOffset z offset
+ * @returns translated Shape
+ *
+ * @category Transformations
+ */
+export function translate(
+ operable: Operable,
+ xOffset: number,
+ yOffset: number,
+ zOffset: number,
+): Operable {
+ return operable.translate([xOffset, yOffset, zOffset]);
+}
+
+/**
+ * Sequentially rotates the specified Operable about the x, y, and z axes using
+ * the specified angles, in radians (i.e. 2π represents 360°).
+ *
+ * The order of rotation is: x, y, then z axis. The order of rotation can affect
+ * the result, so you may wish to make multiple separate calls to rotate() if
+ * you require a specific order of rotation.
+ *
+ * @param operable Shape or Group
+ * @param xAngle x angle in radians
+ * @param yAngle y angle in radians
+ * @param zAngle z angle in radians
+ * @returns rotated Shape
+ *
+ * @category Transformations
+ */
+export function rotate(
+ operable: Operable,
+ xAngle: number,
+ yAngle: number,
+ zAngle: number,
+): Operable {
+ return operable.rotate([xAngle, yAngle, zAngle]);
+}
+
+/**
+ * Scales the specified Operable in the x, y, and z directions using the
+ * specified factors. Scaling is done about the origin (0, 0, 0).
+ *
+ * For example, a factor of 0.5 results in a smaller Shape, while a factor of 2
+ * results in a larger Shape. A factor of 1 results in the original Shape.
+ * Factors must be greater than 0.
+ *
+ * @param operable Shape or Group
+ * @param xFactor x scaling factor
+ * @param yFactor y scaling factor
+ * @param zFactor z scaling factor
+ * @returns scaled Shape
+ *
+ * @category Transformations
+ */
+export function scale(
+ operable: Operable,
+ xFactor: number,
+ yFactor: number,
+ zFactor: number,
+): Operable {
+ if (xFactor <= 0 || yFactor <= 0 || zFactor <= 0) {
+ // JSCAD library does not allow factors <= 0
+ throw new Error('Scaling factor must be greater than 0');
+ }
+
+ return operable.scale([xFactor, yFactor, zFactor]);
+}
+
+// [Functions - Utilities]
+
+/**
+ * Groups the specified list of Operables together. Groups can contain a mix of
+ * Shapes and other nested Groups.
+ *
+ * Groups cannot be operated on, but can be transformed together. I.e. a call
+ * like `intersect(group_a, group_b)` is not allowed, but a call like
+ * `scale(group, 5, 5, 5)` is.
+ *
+ * @param operables list of Shapes and/or Groups
+ * @returns new Group
+ *
+ * @category Utilities
+ */
+export function group(operables: List): Group {
+ if (!is_list(operables)) {
+ throw new Error('Only lists of Operables can be grouped');
+ }
+
+ return new Group(listToArray(operables));
+}
+
+/**
+ * Ungroups the specified Group, returning the list of Shapes and/or nested
+ * Groups contained within.
+ *
+ * @param g Group to ungroup
+ * @returns ungrouped list of Shapes and/or Groups
+ *
+ * @category Utilities
+ */
+export function ungroup(g: Group): List {
+ if (!is_group(g)) {
+ throw new Error('Only Groups can be ungrouped');
+ }
+
+ return arrayToList(g.ungroup());
+}
+
+/**
+ * Checks if the given parameter is a Shape.
+ *
+ * @param parameter parameter to check
+ * @returns whether parameter is a Shape
+ *
+ * @category Utilities
+ */
+export function is_shape(parameter: unknown): boolean {
+ return parameter instanceof Shape;
+}
+
+/**
+ * Checks if the given parameter is a Group.
+ *
+ * @param parameter parameter to check
+ * @returns whether parameter is a Group
+ *
+ * @category Utilities
+ */
+export function is_group(parameter: unknown): boolean {
+ return parameter instanceof Group;
+}
+
+/**
+ * Returns a function of type (string, string) → number, for getting the
+ * specified Shape's bounding box coordinates.
+ *
+ * Its first parameter must be "x", "y", or "z", indicating the coordinate axis.
+ *
+ * Its second parameter must be "min" or "max", indicating the minimum or
+ * maximum bounding box coordinate respectively.
+ *
+ * For example, if a sphere of radius 0.5 is centred at (0.5, 0.5, 0.5), its
+ * minimum bounding coordinates will be (0, 0, 0), and its maximum bounding
+ * coordinates will be (1, 1, 1).
+ *
+ * ```js
+ * // Sample usage
+ * const getter_function = bounding_box(sphere(silver));
+ * display(getter_function("y", "max")); // Displays 1, the maximum y coordinate
+ * ```
+ *
+ * @param shape Shape to measure
+ * @returns bounding box getter function
+ *
+ * @category Utilities
+ */
+export function bounding_box(
+ shape: Shape,
+): (axis: string, minMax: string) => number {
+ let bounds: BoundingBox = measureBoundingBox(shape.solid);
+
+ return (axis: string, minMax: string): number => {
+ let j: number;
+ if (axis === 'x') j = 0;
+ else if (axis === 'y') j = 1;
+ else if (axis === 'z') j = 2;
+ else {
+ throw new Error(
+ `Bounding box getter function expected "x", "y", or "z" as first parameter, but got ${axis}`,
+ );
+ }
+
+ let i: number;
+ if (minMax === 'min') i = 0;
+ else if (minMax === 'max') i = 1;
+ else {
+ throw new Error(
+ `Bounding box getter function expected "min" or "max" as second parameter, but got ${minMax}`,
+ );
+ }
+
+ return bounds[i][j];
+ };
+}
+
+/**
+ * Returns a hex color code representing the specified RGB values.
+ *
+ * @param redValue red value of the color
+ * @param greenValue green value of the color
+ * @param blueValue blue value of the color
+ * @returns hex color code
+ *
+ * @category Utilities
+ */
+export function rgb(
+ redValue: number,
+ greenValue: number,
+ blueValue: number,
+): string {
+ if (
+ redValue < 0
+ || redValue > 255
+ || greenValue < 0
+ || greenValue > 255
+ || blueValue < 0
+ || blueValue > 255
+ ) {
+ throw new Error('RGB values must be between 0 and 255 (inclusive)');
+ }
+
+ return `#${redValue.toString(16)}${greenValue.toString(16)}
+ ${blueValue.toString(16)}`;
+}
+
+/**
+ * Exports the specified Shape as an STL file, downloaded to your device.
+ *
+ * The file can be used for purposes such as 3D printing.
+ *
+ * @param shape Shape to export
+ *
+ * @category Utilities
+ */
+export async function download_shape_stl(shape: Shape): Promise {
+ if (!is_shape(shape)) {
+ throw new Error('Failed to export, only Shapes can be converted to STL');
+ }
+
+ await save(
+ new Blob(serialize({ binary: true }, shape.solid)),
+ 'Source Academy CSG Shape.stl',
+ );
+}
+
+// [Functions - Rendering]
+
+/**
+ * Renders the specified Operable.
+ *
+ * @param operable Shape or Group to render
+ *
+ * @category Rendering
+ */
+export function render(operable: Operable): RenderGroup {
+ if (!(operable instanceof Shape || operable instanceof Group)) {
+ throw new Error('Only Operables can be rendered');
+ }
+
+ operable.store();
+
+ // Trigger a new render group for use with subsequent renders.
+ // Render group is returned for REPL text only; do not document
+ return Core.getRenderGroupManager()
+ .nextRenderGroup();
+}
+
+/**
+ * Renders the specified Operable, along with a grid.
+ *
+ * @param operable Shape or Group to render
+ *
+ * @category Rendering
+ */
+export function render_grid(operable: Operable): RenderGroup {
+ if (!(operable instanceof Shape || operable instanceof Group)) {
+ throw new Error('Only Operables can be rendered');
+ }
+
+ operable.store();
+
+ return Core.getRenderGroupManager()
+ .nextRenderGroup(true);
+}
+
+/**
+ * Renders the specified Operable, along with z, y, and z axes.
+ *
+ * @param operable Shape or Group to render
+ *
+ * @category Rendering
+ */
+export function render_axes(operable: Operable): RenderGroup {
+ if (!(operable instanceof Shape || operable instanceof Group)) {
+ throw new Error('Only Operables can be rendered');
+ }
+
+ operable.store();
+
+ return Core.getRenderGroupManager()
+ .nextRenderGroup(undefined, true);
+}
+
+/**
+ * Renders the specified Operable, along with both a grid and axes.
+ *
+ * @param operable Shape or Group to render
+ *
+ * @category Rendering
+ */
+export function render_grid_axes(operable: Operable): RenderGroup {
+ if (!(operable instanceof Shape || operable instanceof Group)) {
+ throw new Error('Only Operables can be rendered');
+ }
+
+ operable.store();
+
+ return Core.getRenderGroupManager()
+ .nextRenderGroup(true, true);
+}
diff --git a/src/bundles/csg/index.ts b/src/bundles/csg/index.ts
index 71c9ca359..8f58faaed 100644
--- a/src/bundles/csg/index.ts
+++ b/src/bundles/csg/index.ts
@@ -1,74 +1,129 @@
-/* [Imports] */
-import context from 'js-slang/context';
-import { Core } from './core.js';
-import { CsgModuleState } from './utilities.js';
-
-
-
-/* [Main] */
-let moduleState = new CsgModuleState();
-
-context.moduleContexts.csg.state = moduleState;
-// We initialise Core for the first time over on the bundles' end here
-Core.initialize(moduleState);
-
-
-
-/* [Exports] */
-export {
- // Colors
- black,
- navy,
- green,
- teal,
- crimson,
- purple,
- orange,
- silver,
- gray,
- blue,
- lime,
- cyan,
- rose,
- pink,
- yellow,
- white,
-
- // Primitives
- cube,
- rounded_cube,
- cylinder,
- rounded_cylinder,
- sphere,
- geodesic_sphere,
- pyramid,
- cone,
- prism,
- star,
- torus,
-
- // Operations
- union,
- subtract,
- intersect,
-
- // Transformations
- translate,
- rotate,
- scale,
-
- // Utilities
- group,
- ungroup,
- is_shape,
- is_group,
- bounding_box,
- rgb,
- download_shape_stl,
-
- // Rendering
- render,
- render_grid,
- render_axes,
- render_grid_axes,
-} from './functions';
+/**
+ * The CSG module enables working with Constructive Solid Geometry in the Source
+ * Academy. Users are able to program colored 3D models and interact with them
+ * in a tab.
+ *
+ * The main objects in use are called Shapes. Users can create, operate on,
+ * transform, and finally render these Shapes.
+ *
+ * There are also Groups, which contain Shapes, but can also contain other
+ * nested Groups. Groups allow many Shapes to be transformed in tandem, as
+ * opposed to having to call transform functions on each Shape individually.
+ *
+ * An object that is either a Shape or a Group is called an Operable. Operables
+ * as a whole are stateless, which means that passing them into functions does
+ * not modify the original Operable; instead, the newly created Operable is
+ * returned. Therefore, it is safe to reuse existing Operables after passing
+ * them into functions, as they remain immutable.
+ *
+ * When you are done modeling your Operables, pass them to one of the CSG
+ * rendering functions to have them displayed in a tab.
+ *
+ * When rendering, you may optionally render with a grid and/or axes displayed,
+ * depending on the rendering function used. The grid appears on the XY-plane
+ * with white lines every 1 unit of distance, and slightly fainter lines every
+ * 0.25 units of distance. The axes for x, y, and z are coloured red, green, and
+ * blue respectively. The positive z direction is upwards from the flat plane
+ * (right-handed coordinate system).
+ *
+ * ```js
+ * // Sample usage
+ * import {
+ * silver, crimson, cyan,
+ * cube, cone, sphere,
+ * intersect, union, scale, translate,
+ * render_grid_axes
+ * } from "csg";
+ *
+ * const base = intersect(
+ * scale(cube(silver), 1, 1, 0.3),
+ * scale(cone(crimson), 1, 1, 3)
+ * );
+ * const snowglobe = union(
+ * translate(sphere(cyan), 0, 0, 0.22),
+ * base
+ * );
+ * render_grid_axes(snowglobe);
+ * ```
+ *
+ * More samples can be found at: https://github.com/source-academy/modules/tree/master/src/bundles/csg/samples
+ *
+ * @module csg
+ * @author Joel Leow
+ * @author Liu Muchen
+ * @author Ng Yin Joe
+ * @author Yu Chenbo
+ */
+
+/* [Imports] */
+import context from 'js-slang/context';
+import { Core } from './core.js';
+import { CsgModuleState } from './utilities.js';
+
+/* [Main] */
+let moduleState = new CsgModuleState();
+
+context.moduleContexts.csg.state = moduleState;
+// We initialise Core for the first time over on the bundles' end here
+Core.initialize(moduleState);
+
+
+
+/* [Exports] */
+export {
+ // Colors
+ black,
+ navy,
+ green,
+ teal,
+ crimson,
+ purple,
+ orange,
+ silver,
+ gray,
+ blue,
+ lime,
+ cyan,
+ rose,
+ pink,
+ yellow,
+ white,
+
+ // Primitives
+ cube,
+ rounded_cube,
+ cylinder,
+ rounded_cylinder,
+ sphere,
+ geodesic_sphere,
+ pyramid,
+ cone,
+ prism,
+ star,
+ torus,
+
+ // Operations
+ union,
+ subtract,
+ intersect,
+
+ // Transformations
+ translate,
+ rotate,
+ scale,
+
+ // Utilities
+ group,
+ ungroup,
+ is_shape,
+ is_group,
+ bounding_box,
+ rgb,
+ download_shape_stl,
+
+ // Rendering
+ render,
+ render_grid,
+ render_axes,
+ render_grid_axes,
+} from './functions';
diff --git a/src/bundles/csg/input_tracker.ts b/src/bundles/csg/input_tracker.ts
index 4499a124f..1a67a8aec 100644
--- a/src/bundles/csg/input_tracker.ts
+++ b/src/bundles/csg/input_tracker.ts
@@ -1,282 +1,282 @@
-/* [Imports] */
-import vec3 from '@jscad/modeling/src/maths/vec3';
-import { ZOOM_TICK_SCALE } from './constants.js';
-import {
- cloneControlsState,
- pan,
- rotate,
- updateProjection,
- updateStates,
- zoomToFit,
-} from './jscad/renderer.js';
-import type {
- ControlsState,
- GeometryEntity,
- PerspectiveCameraState,
-} from './jscad/types.js';
-import ListenerTracker from './listener_tracker.js';
-
-/* [Main] */
-enum MousePointer {
- // Based on MouseEvent#button
- LEFT = 0,
- MIDDLE = 1,
- RIGHT = 2,
- BACK = 3,
- FORWARD = 4,
-
- NONE = -1,
- OTHER = 7050,
-}
-
-/* [Exports] */
-export default class InputTracker {
- private controlsState: ControlsState = cloneControlsState();
-
- // Start off the first frame by initially zooming to fit
- private zoomToFit: boolean = true;
-
- private zoomTicks: number = 0;
-
- private heldPointer: MousePointer = MousePointer.NONE;
-
- private lastX: number | null = null;
- private lastY: number | null = null;
-
- private rotateX: number = 0;
- private rotateY: number = 0;
- private panX: number = 0;
- private panY: number = 0;
-
- private listenerTracker: ListenerTracker;
-
- // Set to true when a new frame must be requested, as states have changed and
- // the canvas should look different
- public frameDirty: boolean = false;
-
- constructor(
- private canvas: HTMLCanvasElement,
- private cameraState: PerspectiveCameraState,
- private geometryEntities: GeometryEntity[],
- ) {
- this.listenerTracker = new ListenerTracker(canvas);
- }
-
- private changeZoomTicks(wheelDelta: number) {
- // Regardless of scroll magnitude, which the OS can change, each event
- // firing should only tick once up or down
- this.zoomTicks += Math.sign(wheelDelta);
- }
-
- private setHeldPointer(mouseEventButton: number) {
- switch (mouseEventButton) {
- case MousePointer.LEFT:
- case MousePointer.RIGHT:
- case MousePointer.MIDDLE:
- this.heldPointer = mouseEventButton;
- break;
- default:
- this.heldPointer = MousePointer.OTHER;
- break;
- }
- }
-
- private unsetHeldPointer() {
- this.heldPointer = MousePointer.NONE;
- }
-
- private shouldIgnorePointerMove(): boolean {
- return ![MousePointer.LEFT, MousePointer.MIDDLE].includes(this.heldPointer);
- }
-
- private isPointerPan(isShiftKey: boolean): boolean {
- return (
- this.heldPointer === MousePointer.MIDDLE
- || (this.heldPointer === MousePointer.LEFT && isShiftKey)
- );
- }
-
- private unsetLastCoordinates() {
- this.lastX = null;
- this.lastY = null;
- }
-
- private tryDynamicResize() {
- let { width: oldWidth, height: oldHeight } = this.canvas;
-
- // Account for display scaling
- let canvasBounds: DOMRect = this.canvas.getBoundingClientRect();
- let { devicePixelRatio } = window;
- let newWidth: number = Math.floor(canvasBounds.width * devicePixelRatio);
- let newHeight: number = Math.floor(canvasBounds.height * devicePixelRatio);
-
- if (oldWidth === newWidth && oldHeight === newHeight) return;
- this.frameDirty = true;
-
- this.canvas.width = newWidth;
- this.canvas.height = newHeight;
-
- updateProjection(this.cameraState, newWidth, newHeight);
- }
-
- private tryZoomToFit() {
- if (!this.zoomToFit) return;
- this.frameDirty = true;
-
- zoomToFit(this.cameraState, this.controlsState, this.geometryEntities);
-
- this.zoomToFit = false;
- }
-
- private tryZoom() {
- if (this.zoomTicks === 0) return;
-
- while (this.zoomTicks !== 0) {
- let currentTick: number = Math.sign(this.zoomTicks);
- this.zoomTicks -= currentTick;
-
- let scaledChange: number = currentTick * ZOOM_TICK_SCALE;
- let potentialNewScale: number = this.controlsState.scale + scaledChange;
- let potentialNewDistance: number
- = vec3.distance(this.cameraState.position, this.cameraState.target)
- * potentialNewScale;
-
- if (
- potentialNewDistance > this.controlsState.limits.minDistance
- && potentialNewDistance < this.controlsState.limits.maxDistance
- ) {
- this.frameDirty = true;
- this.controlsState.scale = potentialNewScale;
- } else break;
- }
-
- this.zoomTicks = 0;
- }
-
- private tryRotate() {
- if (this.rotateX === 0 && this.rotateY === 0) return;
- this.frameDirty = true;
-
- rotate(this.cameraState, this.controlsState, this.rotateX, this.rotateY);
-
- this.rotateX = 0;
- this.rotateY = 0;
- }
-
- private tryPan() {
- if (this.panX === 0 && this.panY === 0) return;
- this.frameDirty = true;
-
- pan(this.cameraState, this.controlsState, this.panX, this.panY);
-
- this.panX = 0;
- this.panY = 0;
- }
-
- addListeners() {
- this.listenerTracker.addListener('dblclick', (_mouseEvent: MouseEvent) => {
- this.zoomToFit = true;
- });
-
- this.listenerTracker.addListener(
- 'wheel',
- (wheelEvent: WheelEvent) => {
- // Prevent scrolling the side panel when there is overflow
- wheelEvent.preventDefault();
-
- this.changeZoomTicks(wheelEvent.deltaY);
- },
- // Force wait for our potential preventDefault()
- { passive: false },
- );
-
- this.listenerTracker.addListener(
- 'pointerdown',
- (pointerEvent: PointerEvent) => {
- // Prevent middle-click from activating auto-scrolling
- pointerEvent.preventDefault();
-
- this.setHeldPointer(pointerEvent.button);
- this.lastX = pointerEvent.pageX;
- this.lastY = pointerEvent.pageY;
-
- // Detect drags even outside the canvas element's borders
- this.canvas.setPointerCapture(pointerEvent.pointerId);
- },
- // Force wait for our potential preventDefault()
- { passive: false },
- );
-
- this.listenerTracker.addListener(
- 'pointerup',
- (pointerEvent: PointerEvent) => {
- this.unsetHeldPointer();
- this.unsetLastCoordinates();
-
- this.canvas.releasePointerCapture(pointerEvent.pointerId);
- },
- );
-
- this.listenerTracker.addListener(
- 'pointermove',
- (pointerEvent: PointerEvent) => {
- if (this.shouldIgnorePointerMove()) return;
-
- let currentX = pointerEvent.pageX;
- let currentY = pointerEvent.pageY;
-
- if (this.lastX !== null && this.lastY !== null) {
- // If tracked before, use differences to react to input
- let differenceX = this.lastX - currentX;
- let differenceY = this.lastY - currentY;
-
- if (this.isPointerPan(pointerEvent.shiftKey)) {
- // Drag right (X increases)
- // Camera right (still +)
- // Viewport left (invert to -)
- this.panX += differenceX;
-
- // Drag down (Y increases)
- // Camera down (invert to -)
- // Viewport up (still -)
- this.panY -= differenceY;
- } else {
- // Else default to rotate
-
- // Drag right (X increases)
- // Camera angle from origin left (invert to -)
- this.rotateX -= differenceX;
-
- // Drag down (Y increases)
- // Camera angle from origin up (still +)
- this.rotateY += differenceY;
- }
- }
-
- this.lastX = currentX;
- this.lastY = currentY;
- },
- );
- }
-
- removeListeners() {
- this.listenerTracker.removeListeners();
- }
-
- respondToInput() {
- this.tryZoomToFit();
- this.tryZoom();
- this.tryRotate();
- this.tryPan();
- if (this.frameDirty) updateStates(this.cameraState, this.controlsState);
-
- // A successful resize dirties the frame, but does not require
- // updateStates(), only its own updateProjection()
- this.tryDynamicResize();
- }
-
- flushMidInput() {
- this.unsetHeldPointer();
- this.unsetLastCoordinates();
- }
-}
+/* [Imports] */
+import vec3 from '@jscad/modeling/src/maths/vec3';
+import { ZOOM_TICK_SCALE } from './constants.js';
+import {
+ cloneControlsState,
+ pan,
+ rotate,
+ updateProjection,
+ updateStates,
+ zoomToFit,
+} from './jscad/renderer.js';
+import type {
+ ControlsState,
+ GeometryEntity,
+ PerspectiveCameraState,
+} from './jscad/types.js';
+import ListenerTracker from './listener_tracker.js';
+
+/* [Main] */
+enum MousePointer {
+ // Based on MouseEvent#button
+ LEFT = 0,
+ MIDDLE = 1,
+ RIGHT = 2,
+ BACK = 3,
+ FORWARD = 4,
+
+ NONE = -1,
+ OTHER = 7050,
+}
+
+/* [Exports] */
+export default class InputTracker {
+ private controlsState: ControlsState = cloneControlsState();
+
+ // Start off the first frame by initially zooming to fit
+ private zoomToFit: boolean = true;
+
+ private zoomTicks: number = 0;
+
+ private heldPointer: MousePointer = MousePointer.NONE;
+
+ private lastX: number | null = null;
+ private lastY: number | null = null;
+
+ private rotateX: number = 0;
+ private rotateY: number = 0;
+ private panX: number = 0;
+ private panY: number = 0;
+
+ private listenerTracker: ListenerTracker;
+
+ // Set to true when a new frame must be requested, as states have changed and
+ // the canvas should look different
+ public frameDirty: boolean = false;
+
+ constructor(
+ private canvas: HTMLCanvasElement,
+ private cameraState: PerspectiveCameraState,
+ private geometryEntities: GeometryEntity[],
+ ) {
+ this.listenerTracker = new ListenerTracker(canvas);
+ }
+
+ private changeZoomTicks(wheelDelta: number) {
+ // Regardless of scroll magnitude, which the OS can change, each event
+ // firing should only tick once up or down
+ this.zoomTicks += Math.sign(wheelDelta);
+ }
+
+ private setHeldPointer(mouseEventButton: number) {
+ switch (mouseEventButton) {
+ case MousePointer.LEFT:
+ case MousePointer.RIGHT:
+ case MousePointer.MIDDLE:
+ this.heldPointer = mouseEventButton;
+ break;
+ default:
+ this.heldPointer = MousePointer.OTHER;
+ break;
+ }
+ }
+
+ private unsetHeldPointer() {
+ this.heldPointer = MousePointer.NONE;
+ }
+
+ private shouldIgnorePointerMove(): boolean {
+ return ![MousePointer.LEFT, MousePointer.MIDDLE].includes(this.heldPointer);
+ }
+
+ private isPointerPan(isShiftKey: boolean): boolean {
+ return (
+ this.heldPointer === MousePointer.MIDDLE
+ || (this.heldPointer === MousePointer.LEFT && isShiftKey)
+ );
+ }
+
+ private unsetLastCoordinates() {
+ this.lastX = null;
+ this.lastY = null;
+ }
+
+ private tryDynamicResize() {
+ let { width: oldWidth, height: oldHeight } = this.canvas;
+
+ // Account for display scaling
+ let canvasBounds: DOMRect = this.canvas.getBoundingClientRect();
+ let { devicePixelRatio } = window;
+ let newWidth: number = Math.floor(canvasBounds.width * devicePixelRatio);
+ let newHeight: number = Math.floor(canvasBounds.height * devicePixelRatio);
+
+ if (oldWidth === newWidth && oldHeight === newHeight) return;
+ this.frameDirty = true;
+
+ this.canvas.width = newWidth;
+ this.canvas.height = newHeight;
+
+ updateProjection(this.cameraState, newWidth, newHeight);
+ }
+
+ private tryZoomToFit() {
+ if (!this.zoomToFit) return;
+ this.frameDirty = true;
+
+ zoomToFit(this.cameraState, this.controlsState, this.geometryEntities);
+
+ this.zoomToFit = false;
+ }
+
+ private tryZoom() {
+ if (this.zoomTicks === 0) return;
+
+ while (this.zoomTicks !== 0) {
+ let currentTick: number = Math.sign(this.zoomTicks);
+ this.zoomTicks -= currentTick;
+
+ let scaledChange: number = currentTick * ZOOM_TICK_SCALE;
+ let potentialNewScale: number = this.controlsState.scale + scaledChange;
+ let potentialNewDistance: number
+ = vec3.distance(this.cameraState.position, this.cameraState.target)
+ * potentialNewScale;
+
+ if (
+ potentialNewDistance > this.controlsState.limits.minDistance
+ && potentialNewDistance < this.controlsState.limits.maxDistance
+ ) {
+ this.frameDirty = true;
+ this.controlsState.scale = potentialNewScale;
+ } else break;
+ }
+
+ this.zoomTicks = 0;
+ }
+
+ private tryRotate() {
+ if (this.rotateX === 0 && this.rotateY === 0) return;
+ this.frameDirty = true;
+
+ rotate(this.cameraState, this.controlsState, this.rotateX, this.rotateY);
+
+ this.rotateX = 0;
+ this.rotateY = 0;
+ }
+
+ private tryPan() {
+ if (this.panX === 0 && this.panY === 0) return;
+ this.frameDirty = true;
+
+ pan(this.cameraState, this.controlsState, this.panX, this.panY);
+
+ this.panX = 0;
+ this.panY = 0;
+ }
+
+ addListeners() {
+ this.listenerTracker.addListener('dblclick', (_mouseEvent: MouseEvent) => {
+ this.zoomToFit = true;
+ });
+
+ this.listenerTracker.addListener(
+ 'wheel',
+ (wheelEvent: WheelEvent) => {
+ // Prevent scrolling the side panel when there is overflow
+ wheelEvent.preventDefault();
+
+ this.changeZoomTicks(wheelEvent.deltaY);
+ },
+ // Force wait for our potential preventDefault()
+ { passive: false },
+ );
+
+ this.listenerTracker.addListener(
+ 'pointerdown',
+ (pointerEvent: PointerEvent) => {
+ // Prevent middle-click from activating auto-scrolling
+ pointerEvent.preventDefault();
+
+ this.setHeldPointer(pointerEvent.button);
+ this.lastX = pointerEvent.pageX;
+ this.lastY = pointerEvent.pageY;
+
+ // Detect drags even outside the canvas element's borders
+ this.canvas.setPointerCapture(pointerEvent.pointerId);
+ },
+ // Force wait for our potential preventDefault()
+ { passive: false },
+ );
+
+ this.listenerTracker.addListener(
+ 'pointerup',
+ (pointerEvent: PointerEvent) => {
+ this.unsetHeldPointer();
+ this.unsetLastCoordinates();
+
+ this.canvas.releasePointerCapture(pointerEvent.pointerId);
+ },
+ );
+
+ this.listenerTracker.addListener(
+ 'pointermove',
+ (pointerEvent: PointerEvent) => {
+ if (this.shouldIgnorePointerMove()) return;
+
+ let currentX = pointerEvent.pageX;
+ let currentY = pointerEvent.pageY;
+
+ if (this.lastX !== null && this.lastY !== null) {
+ // If tracked before, use differences to react to input
+ let differenceX = this.lastX - currentX;
+ let differenceY = this.lastY - currentY;
+
+ if (this.isPointerPan(pointerEvent.shiftKey)) {
+ // Drag right (X increases)
+ // Camera right (still +)
+ // Viewport left (invert to -)
+ this.panX += differenceX;
+
+ // Drag down (Y increases)
+ // Camera down (invert to -)
+ // Viewport up (still -)
+ this.panY -= differenceY;
+ } else {
+ // Else default to rotate
+
+ // Drag right (X increases)
+ // Camera angle from origin left (invert to -)
+ this.rotateX -= differenceX;
+
+ // Drag down (Y increases)
+ // Camera angle from origin up (still +)
+ this.rotateY += differenceY;
+ }
+ }
+
+ this.lastX = currentX;
+ this.lastY = currentY;
+ },
+ );
+ }
+
+ removeListeners() {
+ this.listenerTracker.removeListeners();
+ }
+
+ respondToInput() {
+ this.tryZoomToFit();
+ this.tryZoom();
+ this.tryRotate();
+ this.tryPan();
+ if (this.frameDirty) updateStates(this.cameraState, this.controlsState);
+
+ // A successful resize dirties the frame, but does not require
+ // updateStates(), only its own updateProjection()
+ this.tryDynamicResize();
+ }
+
+ flushMidInput() {
+ this.unsetHeldPointer();
+ this.unsetLastCoordinates();
+ }
+}
diff --git a/src/bundles/csg/jscad/renderer.ts b/src/bundles/csg/jscad/renderer.ts
index 8f9a785da..5742f69ae 100644
--- a/src/bundles/csg/jscad/renderer.ts
+++ b/src/bundles/csg/jscad/renderer.ts
@@ -1,255 +1,255 @@
-/* [Imports] */
-import measureBoundingBox from '@jscad/modeling/src/measurements/measureBoundingBox';
-import {
- cameras,
- controls,
- drawCommands,
- entitiesFromSolids,
- prepareRender,
-} from '@jscad/regl-renderer';
-import {
- DEFAULT_COLOR,
- GRID_PADDING,
- MAIN_TICKS,
- ROTATION_SPEED,
- ROUND_UP_INTERVAL,
- SUB_TICKS,
- X_FACTOR,
- Y_FACTOR,
-} from '../constants.js';
-import { hexToAlphaColor, type RenderGroup, type Shape } from '../utilities.js';
-import type {
- AlphaColor,
- AxisEntityType,
- BoundingBox,
- ControlsState,
- EntitiesFromSolidsOptions,
- Entity,
- GeometryEntity,
- MultiGridEntityType,
- PanStates,
- PerspectiveCameraState,
- RotateStates,
- Solid,
- UpdatedStates,
- WrappedRenderer,
- WrappedRendererData,
- ZoomToFitStates,
-} from './types.js';
-import { ACE_GUTTER_BACKGROUND_COLOR, ACE_GUTTER_TEXT_COLOR, BP_TEXT_COLOR } from '../../../tabs/common/css_constants.js';
-
-
-
-/* [Main] */
-let { orbit } = controls;
-
-function solidsToGeometryEntities(solids: Solid[]): GeometryEntity[] {
- let options: EntitiesFromSolidsOptions = {
- color: hexToAlphaColor(DEFAULT_COLOR),
- };
- return (entitiesFromSolids(
- options,
- ...solids,
- ) as unknown) as GeometryEntity[];
-}
-
-function neatGridDistance(rawDistance: number) {
- let paddedDistance: number = rawDistance + GRID_PADDING;
- let roundedDistance: number
- = Math.ceil(paddedDistance / ROUND_UP_INTERVAL) * ROUND_UP_INTERVAL;
- return roundedDistance;
-}
-
-class MultiGridEntity implements MultiGridEntityType {
- visuals: {
- drawCmd: 'drawGrid';
- show: boolean;
-
- color?: AlphaColor;
- subColor?: AlphaColor;
- } = {
- drawCmd: 'drawGrid',
- show: true,
-
- color: hexToAlphaColor(BP_TEXT_COLOR),
- subColor: hexToAlphaColor(ACE_GUTTER_TEXT_COLOR),
- };
-
- ticks: [number, number] = [MAIN_TICKS, SUB_TICKS];
-
- size: [number, number];
-
- constructor(size: number) {
- this.size = [size, size];
- }
-}
-
-class AxisEntity implements AxisEntityType {
- visuals: {
- drawCmd: 'drawAxis';
- show: boolean;
- } = {
- drawCmd: 'drawAxis',
- show: true,
- };
-
- alwaysVisible: boolean = false;
-
- constructor(public size?: number) {}
-}
-
-function makeExtraEntities(
- renderGroup: RenderGroup,
- solids: Solid[],
-): Entity[] {
- let { hasGrid, hasAxis } = renderGroup;
- // Run calculations for grid and/or axis only if needed
- if (!(hasAxis || hasGrid)) return [];
-
- let boundingBoxes: BoundingBox[] = solids.map(
- (solid: Solid): BoundingBox => measureBoundingBox(solid),
- );
- let minMaxXys: number[][] = boundingBoxes.map(
- (boundingBox: BoundingBox): number[] => {
- let minX = boundingBox[0][0];
- let minY = boundingBox[0][1];
- let maxX = boundingBox[1][0];
- let maxY = boundingBox[1][1];
- return [minX, minY, maxX, maxY];
- },
- );
- let xys: number[] = minMaxXys.flat(1);
- let distancesFromOrigin: number[] = xys.map(Math.abs);
- let furthestDistance: number = Math.max(...distancesFromOrigin);
- let neatDistance: number = neatGridDistance(furthestDistance);
-
- let extraEntities: Entity[] = [];
- if (hasGrid) extraEntities.push(new MultiGridEntity(neatDistance * 2));
- if (hasAxis) extraEntities.push(new AxisEntity(neatDistance));
- return extraEntities;
-}
-
-/* [Exports] */
-export function makeWrappedRendererData(
- renderGroup: RenderGroup,
- cameraState: PerspectiveCameraState,
-): WrappedRendererData {
- let solids: Solid[] = renderGroup.shapes.map(
- (shape: Shape): Solid => shape.solid,
- );
- let geometryEntities: GeometryEntity[] = solidsToGeometryEntities(solids);
- let extraEntities: Entity[] = makeExtraEntities(renderGroup, solids);
- let allEntities: Entity[] = [...geometryEntities, ...extraEntities];
-
- return {
- entities: allEntities,
- geometryEntities,
-
- camera: cameraState,
-
- rendering: {
- background: hexToAlphaColor(ACE_GUTTER_BACKGROUND_COLOR),
- },
-
- drawCommands,
- };
-}
-
-export function makeWrappedRenderer(
- canvas: HTMLCanvasElement,
-): WrappedRenderer {
- return prepareRender({
- // Used to initialise Regl from the REGL package constructor
- glOptions: { canvas },
- });
-}
-
-export function cloneCameraState(): PerspectiveCameraState {
- return { ...cameras.perspective.defaults };
-}
-export function cloneControlsState(): ControlsState {
- return { ...controls.orbit.defaults };
-}
-
-export function updateProjection(
- cameraState: PerspectiveCameraState,
- width: number,
- height: number,
-) {
- // Modify the projection, aspect ratio & viewport. As compared to the general
- // controls.orbit.update() or even cameras.perspective.update()
- cameras.perspective.setProjection(cameraState, cameraState, {
- width,
- height,
- });
-}
-
-export function updateStates(
- cameraState: PerspectiveCameraState,
- controlsState: ControlsState,
-) {
- let states: UpdatedStates = (orbit.update({
- camera: cameraState,
- controls: controlsState,
- }) as unknown) as UpdatedStates;
-
- cameraState.position = states.camera.position;
- cameraState.view = states.camera.view;
-
- controlsState.thetaDelta = states.controls.thetaDelta;
- controlsState.phiDelta = states.controls.phiDelta;
- controlsState.scale = states.controls.scale;
-}
-
-export function zoomToFit(
- cameraState: PerspectiveCameraState,
- controlsState: ControlsState,
- geometryEntities: GeometryEntity[],
-) {
- let states: ZoomToFitStates = (orbit.zoomToFit({
- camera: cameraState,
- controls: controlsState,
- entities: geometryEntities as any,
- }) as unknown) as ZoomToFitStates;
-
- cameraState.target = states.camera.target;
-
- controlsState.scale = states.controls.scale;
-}
-
-export function rotate(
- cameraState: PerspectiveCameraState,
- controlsState: ControlsState,
- rotateX: number,
- rotateY: number,
-) {
- let states: RotateStates = (orbit.rotate(
- {
- camera: cameraState,
- controls: controlsState,
- speed: ROTATION_SPEED,
- },
- [rotateX, rotateY],
- ) as unknown) as RotateStates;
-
- controlsState.thetaDelta = states.controls.thetaDelta;
- controlsState.phiDelta = states.controls.phiDelta;
-}
-
-export function pan(
- cameraState: PerspectiveCameraState,
- controlsState: ControlsState,
- panX: number,
- panY: number,
-) {
- let states: PanStates = (orbit.pan(
- {
- camera: cameraState,
- controls: controlsState,
- },
- [panX * X_FACTOR, panY * Y_FACTOR],
- ) as unknown) as PanStates;
-
- cameraState.position = states.camera.position;
- cameraState.target = states.camera.target;
-}
+/* [Imports] */
+import measureBoundingBox from '@jscad/modeling/src/measurements/measureBoundingBox';
+import {
+ cameras,
+ controls,
+ drawCommands,
+ entitiesFromSolids,
+ prepareRender,
+} from '@jscad/regl-renderer';
+import {
+ DEFAULT_COLOR,
+ GRID_PADDING,
+ MAIN_TICKS,
+ ROTATION_SPEED,
+ ROUND_UP_INTERVAL,
+ SUB_TICKS,
+ X_FACTOR,
+ Y_FACTOR,
+} from '../constants.js';
+import { hexToAlphaColor, type RenderGroup, type Shape } from '../utilities.js';
+import type {
+ AlphaColor,
+ AxisEntityType,
+ BoundingBox,
+ ControlsState,
+ EntitiesFromSolidsOptions,
+ Entity,
+ GeometryEntity,
+ MultiGridEntityType,
+ PanStates,
+ PerspectiveCameraState,
+ RotateStates,
+ Solid,
+ UpdatedStates,
+ WrappedRenderer,
+ WrappedRendererData,
+ ZoomToFitStates,
+} from './types.js';
+import { ACE_GUTTER_BACKGROUND_COLOR, ACE_GUTTER_TEXT_COLOR, BP_TEXT_COLOR } from '../../../tabs/common/css_constants.js';
+
+
+
+/* [Main] */
+let { orbit } = controls;
+
+function solidsToGeometryEntities(solids: Solid[]): GeometryEntity[] {
+ let options: EntitiesFromSolidsOptions = {
+ color: hexToAlphaColor(DEFAULT_COLOR),
+ };
+ return (entitiesFromSolids(
+ options,
+ ...solids,
+ ) as unknown) as GeometryEntity[];
+}
+
+function neatGridDistance(rawDistance: number) {
+ let paddedDistance: number = rawDistance + GRID_PADDING;
+ let roundedDistance: number
+ = Math.ceil(paddedDistance / ROUND_UP_INTERVAL) * ROUND_UP_INTERVAL;
+ return roundedDistance;
+}
+
+class MultiGridEntity implements MultiGridEntityType {
+ visuals: {
+ drawCmd: 'drawGrid';
+ show: boolean;
+
+ color?: AlphaColor;
+ subColor?: AlphaColor;
+ } = {
+ drawCmd: 'drawGrid',
+ show: true,
+
+ color: hexToAlphaColor(BP_TEXT_COLOR),
+ subColor: hexToAlphaColor(ACE_GUTTER_TEXT_COLOR),
+ };
+
+ ticks: [number, number] = [MAIN_TICKS, SUB_TICKS];
+
+ size: [number, number];
+
+ constructor(size: number) {
+ this.size = [size, size];
+ }
+}
+
+class AxisEntity implements AxisEntityType {
+ visuals: {
+ drawCmd: 'drawAxis';
+ show: boolean;
+ } = {
+ drawCmd: 'drawAxis',
+ show: true,
+ };
+
+ alwaysVisible: boolean = false;
+
+ constructor(public size?: number) {}
+}
+
+function makeExtraEntities(
+ renderGroup: RenderGroup,
+ solids: Solid[],
+): Entity[] {
+ let { hasGrid, hasAxis } = renderGroup;
+ // Run calculations for grid and/or axis only if needed
+ if (!(hasAxis || hasGrid)) return [];
+
+ let boundingBoxes: BoundingBox[] = solids.map(
+ (solid: Solid): BoundingBox => measureBoundingBox(solid),
+ );
+ let minMaxXys: number[][] = boundingBoxes.map(
+ (boundingBox: BoundingBox): number[] => {
+ let minX = boundingBox[0][0];
+ let minY = boundingBox[0][1];
+ let maxX = boundingBox[1][0];
+ let maxY = boundingBox[1][1];
+ return [minX, minY, maxX, maxY];
+ },
+ );
+ let xys: number[] = minMaxXys.flat(1);
+ let distancesFromOrigin: number[] = xys.map(Math.abs);
+ let furthestDistance: number = Math.max(...distancesFromOrigin);
+ let neatDistance: number = neatGridDistance(furthestDistance);
+
+ let extraEntities: Entity[] = [];
+ if (hasGrid) extraEntities.push(new MultiGridEntity(neatDistance * 2));
+ if (hasAxis) extraEntities.push(new AxisEntity(neatDistance));
+ return extraEntities;
+}
+
+/* [Exports] */
+export function makeWrappedRendererData(
+ renderGroup: RenderGroup,
+ cameraState: PerspectiveCameraState,
+): WrappedRendererData {
+ let solids: Solid[] = renderGroup.shapes.map(
+ (shape: Shape): Solid => shape.solid,
+ );
+ let geometryEntities: GeometryEntity[] = solidsToGeometryEntities(solids);
+ let extraEntities: Entity[] = makeExtraEntities(renderGroup, solids);
+ let allEntities: Entity[] = [...geometryEntities, ...extraEntities];
+
+ return {
+ entities: allEntities,
+ geometryEntities,
+
+ camera: cameraState,
+
+ rendering: {
+ background: hexToAlphaColor(ACE_GUTTER_BACKGROUND_COLOR),
+ },
+
+ drawCommands,
+ };
+}
+
+export function makeWrappedRenderer(
+ canvas: HTMLCanvasElement,
+): WrappedRenderer {
+ return prepareRender({
+ // Used to initialise Regl from the REGL package constructor
+ glOptions: { canvas },
+ });
+}
+
+export function cloneCameraState(): PerspectiveCameraState {
+ return { ...cameras.perspective.defaults };
+}
+export function cloneControlsState(): ControlsState {
+ return { ...controls.orbit.defaults };
+}
+
+export function updateProjection(
+ cameraState: PerspectiveCameraState,
+ width: number,
+ height: number,
+) {
+ // Modify the projection, aspect ratio & viewport. As compared to the general
+ // controls.orbit.update() or even cameras.perspective.update()
+ cameras.perspective.setProjection(cameraState, cameraState, {
+ width,
+ height,
+ });
+}
+
+export function updateStates(
+ cameraState: PerspectiveCameraState,
+ controlsState: ControlsState,
+) {
+ let states: UpdatedStates = (orbit.update({
+ camera: cameraState,
+ controls: controlsState,
+ }) as unknown) as UpdatedStates;
+
+ cameraState.position = states.camera.position;
+ cameraState.view = states.camera.view;
+
+ controlsState.thetaDelta = states.controls.thetaDelta;
+ controlsState.phiDelta = states.controls.phiDelta;
+ controlsState.scale = states.controls.scale;
+}
+
+export function zoomToFit(
+ cameraState: PerspectiveCameraState,
+ controlsState: ControlsState,
+ geometryEntities: GeometryEntity[],
+) {
+ let states: ZoomToFitStates = (orbit.zoomToFit({
+ camera: cameraState,
+ controls: controlsState,
+ entities: geometryEntities as any,
+ }) as unknown) as ZoomToFitStates;
+
+ cameraState.target = states.camera.target;
+
+ controlsState.scale = states.controls.scale;
+}
+
+export function rotate(
+ cameraState: PerspectiveCameraState,
+ controlsState: ControlsState,
+ rotateX: number,
+ rotateY: number,
+) {
+ let states: RotateStates = (orbit.rotate(
+ {
+ camera: cameraState,
+ controls: controlsState,
+ speed: ROTATION_SPEED,
+ },
+ [rotateX, rotateY],
+ ) as unknown) as RotateStates;
+
+ controlsState.thetaDelta = states.controls.thetaDelta;
+ controlsState.phiDelta = states.controls.phiDelta;
+}
+
+export function pan(
+ cameraState: PerspectiveCameraState,
+ controlsState: ControlsState,
+ panX: number,
+ panY: number,
+) {
+ let states: PanStates = (orbit.pan(
+ {
+ camera: cameraState,
+ controls: controlsState,
+ },
+ [panX * X_FACTOR, panY * Y_FACTOR],
+ ) as unknown) as PanStates;
+
+ cameraState.position = states.camera.position;
+ cameraState.target = states.camera.target;
+}
diff --git a/src/bundles/csg/jscad/types.ts b/src/bundles/csg/jscad/types.ts
index b05fd07e6..010a221cc 100644
--- a/src/bundles/csg/jscad/types.ts
+++ b/src/bundles/csg/jscad/types.ts
@@ -1,276 +1,276 @@
-/* [Import] */
-import type { RGB, RGBA } from '@jscad/modeling/src/colors/types.js';
-import type { Geom3 } from '@jscad/modeling/src/geometries/types.js';
-import { type cameras, type drawCommands, controls } from '@jscad/regl-renderer';
-import type makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi';
-
-/* [Main] */
-let { orbit } = controls;
-
-/* [Exports] */
-export type Color = RGB;
-export type AlphaColor = RGBA;
-
-export type Numbers2 = [number, number];
-export type Numbers3 = [number, number, number];
-
-export type Vector = Numbers3;
-export type Coordinates = Numbers3;
-export type BoundingBox = [Coordinates, Coordinates];
-
-// @jscad\regl-renderer\src\rendering\renderDefaults.js
-export type RenderOptions = {
- // Used early on in render.js. Clears the canvas to the specified background
- // colour
- background?: AlphaColor;
-
- // Specified values used in various rendering commands as shader uniforms
- lightColor?: AlphaColor;
- lightDirection?: Vector;
- ambientLightAmount?: number;
- diffuseLightAmount?: number;
- specularLightAmount?: number;
- materialShininess?: number; // As uMaterialShininess in main DrawCommand
-
- // Specified value is unused, which seems unintentional. Their default value
- // for this is used directly in V2's entitiesFromSolids.js as the default
- // Geometry colour. Also gets used directly in various rendering commands as
- // their shader uniforms' default colour
- meshColor?: AlphaColor;
-
- // Unused
- lightPosition?: Coordinates; // See also lightDirection
-};
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-// @jscad\regl-renderer\demo-web.js
-// @jscad\regl-renderer\src\rendering\render.js
-/*
- (This is not exhaustive. There are still other Props used for uniforms in the
- various rendering commands. Eg model, color, angle.)
- (There are other Entity subtypes not defined in this file - GridEntity,
- LineEntity & MeshEntity)
-*/
-export type Entity = {
- visuals: {
- // Key for the DrawCommandMaker that should be used on this Entity. Key gets
- // used on WrappedRendererData#drawCommands. Property must exist & match a
- // DrawCommandMaker, or behaviour is like show: false
- drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh';
-
- // Whether to actually draw the Entity via nested DrawCommand
- show: boolean;
-
- // Used to retrieve created DrawCommands from cache
- cacheId?: number | null;
- };
-};
-
-// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js
-// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js
-export type Geometry = {
- type: '2d' | '3d';
- positions: Coordinates[];
- normals: Coordinates[];
- indices: Coordinates[];
- colors: AlphaColor[];
- transforms: Mat4;
- isTransparent: boolean;
-};
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-export type GeometryEntity = Entity & {
- visuals: {
- drawCmd: 'drawLines' | 'drawMesh';
-
- // Whether the Geometry is transparent. Transparents need to be rendered
- // before non-transparents
- transparent: boolean;
-
- // Eventually determines whether to use vColorShaders (Geometry must also
- // have colour), or meshShaders
- useVertexColors: boolean;
- };
-
- // The original Geometry used to make the GeometryEntity
- geometry: Geometry;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js
-// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js
-// @jscad\regl-renderer\demo-web.js
-// @jscad\web\src\ui\views\viewer.js
-// @jscad\regl-renderer\src\index.js
-export type MultiGridEntityType = Entity & {
- // Entity#visuals gets stuffed into the nested DrawCommand as Props. The Props
- // get passed on wholesale by makeDrawMultiGrid()'s returned lambda, where the
- // following properties then get used (rather than while setting up the
- // DrawCommands)
- visuals: {
- drawCmd: 'drawGrid';
-
- color?: AlphaColor;
- subColor?: AlphaColor; // Also as color
-
- fadeOut?: boolean;
- };
-
- size?: Numbers2;
- // First number used on the main grid, second number on sub grid
- ticks?: [number, number];
-
- centered?: boolean;
-
- // Deprecated
- lineWidth?: number;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js
-// @jscad\regl-renderer\demo-web.js
-export type AxisEntityType = Entity & {
- visuals: {
- drawCmd: 'drawAxis';
- };
-
- size?: number;
- alwaysVisible?: boolean;
-
- xColor?: AlphaColor;
- yColor?: AlphaColor;
- zColor?: AlphaColor;
-
- // Deprecated
- lineWidth?: number;
-};
-
-// There are 4 rendering commands to use in regl-renderer: drawAxis, drawGrid,
-// drawLines & drawMesh. drawExps appears abandoned. Only once passed Regl & an
-// Entity do they return an actual DrawCommand
-export type DrawCommandMaker = typeof drawCommands | typeof makeDrawMultiGrid;
-export type DrawCommandMakers = Record;
-
-// @jscad\regl-renderer\src\cameras\perspectiveCamera.js
-// @jscad\regl-renderer\src\cameras\orthographicCamera.js
-/*
- (Not exhaustive, only defines well the important properties we need.)
- (Orthgraphic camera is ignored, this file assumes PerspectiveCameraState)
-*/
-export type Mat4 = Float32Array;
-export type PerspectiveCameraState = Omit<
- typeof cameras.perspective.cameraState,
-'target' | 'position' | 'view'
-> & {
- target: Coordinates;
-
- position: Coordinates;
- view: Mat4;
-};
-
-// @jscad\regl-renderer\src\rendering\render.js
-/*
- Gets used in the WrappedRenderer. Also gets passed as Props into the main
- DrawCommand, where it is used in setup specified by the internal
- renderContext.js/renderWrapper. The lambda of the main DrawCommand does not
- use those Props, rather, it references the data in the WrappedRenderer
- directly. Therefore, regl.prop() is not called, nor are Props used via the
- semantic equivalent (context, props) => {}. The context passed to that lambda
- also remains unused
-*/
-export type WrappedRendererData = {
- entities: Entity[];
- // A CUSTOM property used to easily pass only the GeometryEntities to
- // InputTracker for zoom to fit
- geometryEntities: GeometryEntity[];
-
- // Along with all of the relevant Entity's & Entity#visuals's properties, this
- // entire object gets destructured & stuffed into each nested DrawCommand as
- // Props. Messy & needs tidying in regl-renderer
- camera: PerspectiveCameraState;
-
- rendering?: RenderOptions;
-
- drawCommands: DrawCommandMakers;
-};
-
-// @jscad\regl-renderer\src\rendering\render.js
-/*
- When called, the WrappedRenderer creates a main DrawCommand. This main
- DrawCommand then gets called as a scoped command, used to create & call more
- DrawCommands for the data.entities. Nested DrawCommands get cached & may store
- some Entity properties during setup, but properties passed in from Props later
- may take precedence. The main DrawCommand is said to be in charge of injecting
- most uniforms into the Regl context, ie keeping track of all Regl global state
-*/
-export type WrappedRenderer = (data: WrappedRendererData) => void;
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-/*
- (Not exhaustive, only defines well the important properties we need)
-*/
-export type ControlsState = Omit<
- typeof orbit.controlsState,
-'scale' | 'thetaDelta' | 'phiDelta'
-> &
- typeof orbit.controlsProps & {
- scale: number;
-
- thetaDelta: number;
- phiDelta: number;
-};
-
-export type Solid = Geom3;
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-/*
- Options for the function that converts Solids into Geometries, then into
- GeometryEntities
-*/
-export type EntitiesFromSolidsOptions = {
- // Default colour for entity rendering if the solid does not have one
- color?: AlphaColor;
-
- // Whether to smooth the normals of 3D solids, rendering a smooth surface
- smoothNormals?: boolean;
-};
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-export type UpdatedStates = {
- camera: {
- position: Coordinates;
- view: Mat4;
- };
- controls: {
- thetaDelta: number;
- phiDelta: number;
- scale: number;
- changed: boolean;
- };
-};
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-export type ZoomToFitStates = {
- camera: {
- target: Vector;
- };
- controls: {
- scale: number;
- };
-};
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-export type RotateStates = {
- camera: PerspectiveCameraState;
- controls: {
- thetaDelta: number;
- phiDelta: number;
- };
-};
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-export type PanStates = {
- camera: {
- position: Coordinates;
- target: Vector;
- };
- controls: ControlsState;
-};
+/* [Import] */
+import type { RGB, RGBA } from '@jscad/modeling/src/colors/types.js';
+import type { Geom3 } from '@jscad/modeling/src/geometries/types.js';
+import { type cameras, type drawCommands, controls } from '@jscad/regl-renderer';
+import type makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi';
+
+/* [Main] */
+let { orbit } = controls;
+
+/* [Exports] */
+export type Color = RGB;
+export type AlphaColor = RGBA;
+
+export type Numbers2 = [number, number];
+export type Numbers3 = [number, number, number];
+
+export type Vector = Numbers3;
+export type Coordinates = Numbers3;
+export type BoundingBox = [Coordinates, Coordinates];
+
+// @jscad\regl-renderer\src\rendering\renderDefaults.js
+export type RenderOptions = {
+ // Used early on in render.js. Clears the canvas to the specified background
+ // colour
+ background?: AlphaColor;
+
+ // Specified values used in various rendering commands as shader uniforms
+ lightColor?: AlphaColor;
+ lightDirection?: Vector;
+ ambientLightAmount?: number;
+ diffuseLightAmount?: number;
+ specularLightAmount?: number;
+ materialShininess?: number; // As uMaterialShininess in main DrawCommand
+
+ // Specified value is unused, which seems unintentional. Their default value
+ // for this is used directly in V2's entitiesFromSolids.js as the default
+ // Geometry colour. Also gets used directly in various rendering commands as
+ // their shader uniforms' default colour
+ meshColor?: AlphaColor;
+
+ // Unused
+ lightPosition?: Coordinates; // See also lightDirection
+};
+
+// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
+// @jscad\regl-renderer\demo-web.js
+// @jscad\regl-renderer\src\rendering\render.js
+/*
+ (This is not exhaustive. There are still other Props used for uniforms in the
+ various rendering commands. Eg model, color, angle.)
+ (There are other Entity subtypes not defined in this file - GridEntity,
+ LineEntity & MeshEntity)
+*/
+export type Entity = {
+ visuals: {
+ // Key for the DrawCommandMaker that should be used on this Entity. Key gets
+ // used on WrappedRendererData#drawCommands. Property must exist & match a
+ // DrawCommandMaker, or behaviour is like show: false
+ drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh';
+
+ // Whether to actually draw the Entity via nested DrawCommand
+ show: boolean;
+
+ // Used to retrieve created DrawCommands from cache
+ cacheId?: number | null;
+ };
+};
+
+// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js
+// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js
+export type Geometry = {
+ type: '2d' | '3d';
+ positions: Coordinates[];
+ normals: Coordinates[];
+ indices: Coordinates[];
+ colors: AlphaColor[];
+ transforms: Mat4;
+ isTransparent: boolean;
+};
+
+// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
+export type GeometryEntity = Entity & {
+ visuals: {
+ drawCmd: 'drawLines' | 'drawMesh';
+
+ // Whether the Geometry is transparent. Transparents need to be rendered
+ // before non-transparents
+ transparent: boolean;
+
+ // Eventually determines whether to use vColorShaders (Geometry must also
+ // have colour), or meshShaders
+ useVertexColors: boolean;
+ };
+
+ // The original Geometry used to make the GeometryEntity
+ geometry: Geometry;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js
+// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js
+// @jscad\regl-renderer\demo-web.js
+// @jscad\web\src\ui\views\viewer.js
+// @jscad\regl-renderer\src\index.js
+export type MultiGridEntityType = Entity & {
+ // Entity#visuals gets stuffed into the nested DrawCommand as Props. The Props
+ // get passed on wholesale by makeDrawMultiGrid()'s returned lambda, where the
+ // following properties then get used (rather than while setting up the
+ // DrawCommands)
+ visuals: {
+ drawCmd: 'drawGrid';
+
+ color?: AlphaColor;
+ subColor?: AlphaColor; // Also as color
+
+ fadeOut?: boolean;
+ };
+
+ size?: Numbers2;
+ // First number used on the main grid, second number on sub grid
+ ticks?: [number, number];
+
+ centered?: boolean;
+
+ // Deprecated
+ lineWidth?: number;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js
+// @jscad\regl-renderer\demo-web.js
+export type AxisEntityType = Entity & {
+ visuals: {
+ drawCmd: 'drawAxis';
+ };
+
+ size?: number;
+ alwaysVisible?: boolean;
+
+ xColor?: AlphaColor;
+ yColor?: AlphaColor;
+ zColor?: AlphaColor;
+
+ // Deprecated
+ lineWidth?: number;
+};
+
+// There are 4 rendering commands to use in regl-renderer: drawAxis, drawGrid,
+// drawLines & drawMesh. drawExps appears abandoned. Only once passed Regl & an
+// Entity do they return an actual DrawCommand
+export type DrawCommandMaker = typeof drawCommands | typeof makeDrawMultiGrid;
+export type DrawCommandMakers = Record;
+
+// @jscad\regl-renderer\src\cameras\perspectiveCamera.js
+// @jscad\regl-renderer\src\cameras\orthographicCamera.js
+/*
+ (Not exhaustive, only defines well the important properties we need.)
+ (Orthgraphic camera is ignored, this file assumes PerspectiveCameraState)
+*/
+export type Mat4 = Float32Array;
+export type PerspectiveCameraState = Omit<
+ typeof cameras.perspective.cameraState,
+'target' | 'position' | 'view'
+> & {
+ target: Coordinates;
+
+ position: Coordinates;
+ view: Mat4;
+};
+
+// @jscad\regl-renderer\src\rendering\render.js
+/*
+ Gets used in the WrappedRenderer. Also gets passed as Props into the main
+ DrawCommand, where it is used in setup specified by the internal
+ renderContext.js/renderWrapper. The lambda of the main DrawCommand does not
+ use those Props, rather, it references the data in the WrappedRenderer
+ directly. Therefore, regl.prop() is not called, nor are Props used via the
+ semantic equivalent (context, props) => {}. The context passed to that lambda
+ also remains unused
+*/
+export type WrappedRendererData = {
+ entities: Entity[];
+ // A CUSTOM property used to easily pass only the GeometryEntities to
+ // InputTracker for zoom to fit
+ geometryEntities: GeometryEntity[];
+
+ // Along with all of the relevant Entity's & Entity#visuals's properties, this
+ // entire object gets destructured & stuffed into each nested DrawCommand as
+ // Props. Messy & needs tidying in regl-renderer
+ camera: PerspectiveCameraState;
+
+ rendering?: RenderOptions;
+
+ drawCommands: DrawCommandMakers;
+};
+
+// @jscad\regl-renderer\src\rendering\render.js
+/*
+ When called, the WrappedRenderer creates a main DrawCommand. This main
+ DrawCommand then gets called as a scoped command, used to create & call more
+ DrawCommands for the data.entities. Nested DrawCommands get cached & may store
+ some Entity properties during setup, but properties passed in from Props later
+ may take precedence. The main DrawCommand is said to be in charge of injecting
+ most uniforms into the Regl context, ie keeping track of all Regl global state
+*/
+export type WrappedRenderer = (data: WrappedRendererData) => void;
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+/*
+ (Not exhaustive, only defines well the important properties we need)
+*/
+export type ControlsState = Omit<
+ typeof orbit.controlsState,
+'scale' | 'thetaDelta' | 'phiDelta'
+> &
+ typeof orbit.controlsProps & {
+ scale: number;
+
+ thetaDelta: number;
+ phiDelta: number;
+};
+
+export type Solid = Geom3;
+
+// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
+/*
+ Options for the function that converts Solids into Geometries, then into
+ GeometryEntities
+*/
+export type EntitiesFromSolidsOptions = {
+ // Default colour for entity rendering if the solid does not have one
+ color?: AlphaColor;
+
+ // Whether to smooth the normals of 3D solids, rendering a smooth surface
+ smoothNormals?: boolean;
+};
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+export type UpdatedStates = {
+ camera: {
+ position: Coordinates;
+ view: Mat4;
+ };
+ controls: {
+ thetaDelta: number;
+ phiDelta: number;
+ scale: number;
+ changed: boolean;
+ };
+};
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+export type ZoomToFitStates = {
+ camera: {
+ target: Vector;
+ };
+ controls: {
+ scale: number;
+ };
+};
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+export type RotateStates = {
+ camera: PerspectiveCameraState;
+ controls: {
+ thetaDelta: number;
+ phiDelta: number;
+ };
+};
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+export type PanStates = {
+ camera: {
+ position: Coordinates;
+ target: Vector;
+ };
+ controls: ControlsState;
+};
diff --git a/src/bundles/csg/listener_tracker.ts b/src/bundles/csg/listener_tracker.ts
index aeba0fda6..79539fc99 100644
--- a/src/bundles/csg/listener_tracker.ts
+++ b/src/bundles/csg/listener_tracker.ts
@@ -1,28 +1,28 @@
-/* [Exports] */
-export default class ListenerTracker {
- private listeners: [string, Function][] = [];
-
- constructor(private element: Element) {}
-
- addListener(
- eventType: string,
- listener: Function,
- options?: AddEventListenerOptions,
- ) {
- this.listeners.push([eventType, listener]);
- this.element.addEventListener(
- eventType,
- listener as EventListenerOrEventListenerObject,
- options,
- );
- }
-
- removeListeners() {
- this.listeners.forEach(([eventType, listener]) => {
- this.element.removeEventListener(
- eventType,
- listener as EventListenerOrEventListenerObject,
- );
- });
- }
-}
+/* [Exports] */
+export default class ListenerTracker {
+ private listeners: [string, Function][] = [];
+
+ constructor(private element: Element) {}
+
+ addListener(
+ eventType: string,
+ listener: Function,
+ options?: AddEventListenerOptions,
+ ) {
+ this.listeners.push([eventType, listener]);
+ this.element.addEventListener(
+ eventType,
+ listener as EventListenerOrEventListenerObject,
+ options,
+ );
+ }
+
+ removeListeners() {
+ this.listeners.forEach(([eventType, listener]) => {
+ this.element.removeEventListener(
+ eventType,
+ listener as EventListenerOrEventListenerObject,
+ );
+ });
+ }
+}
diff --git a/src/bundles/csg/stateful_renderer.ts b/src/bundles/csg/stateful_renderer.ts
index 8bf46625e..ff7bef15a 100644
--- a/src/bundles/csg/stateful_renderer.ts
+++ b/src/bundles/csg/stateful_renderer.ts
@@ -1,140 +1,140 @@
-/* [Imports] */
-import InputTracker from './input_tracker.js';
-import {
- cloneCameraState,
- makeWrappedRenderer,
- makeWrappedRendererData,
-} from './jscad/renderer.js';
-import type {
- Entity,
- PerspectiveCameraState,
- WrappedRenderer,
- WrappedRendererData,
-} from './jscad/types.js';
-import ListenerTracker from './listener_tracker.js';
-import type { RenderGroup } from './utilities.js';
-
-/* [Exports] */
-export default class StatefulRenderer {
- private isStarted: boolean = false;
- private currentRequestId: number | null = null;
-
- private cameraState: PerspectiveCameraState = cloneCameraState();
-
- private webGlListenerTracker: ListenerTracker;
-
- private wrappedRendererData: WrappedRendererData;
-
- private inputTracker: InputTracker;
-
- constructor(
- private canvas: HTMLCanvasElement,
- renderGroup: RenderGroup,
- private componentNumber: number,
-
- private loseCallback: Function,
- private restoreCallback: Function,
- ) {
- this.cameraState.position = [1000, 1000, 1500];
-
- this.webGlListenerTracker = new ListenerTracker(canvas);
-
- this.wrappedRendererData = makeWrappedRendererData(
- renderGroup,
- this.cameraState,
- );
-
- this.inputTracker = new InputTracker(
- canvas,
- this.cameraState,
- this.wrappedRendererData.geometryEntities,
- );
- }
-
- private addWebGlListeners() {
- this.webGlListenerTracker.addListener(
- 'webglcontextlost',
- (contextEvent: WebGLContextEvent) => {
- // Allow restoration of context
- contextEvent.preventDefault();
-
- console.debug(`>>> CONTEXT LOST FOR #${this.componentNumber}`);
-
- this.loseCallback();
-
- this.stop();
- },
- );
-
- this.webGlListenerTracker.addListener(
- 'webglcontextrestored',
- (_contextEvent: WebGLContextEvent) => {
- console.debug(`>>> CONTEXT RESTORED FOR #${this.componentNumber}`);
-
- this.start();
-
- this.restoreCallback();
- },
- );
- }
-
- private forgetEntityCaches() {
- // Clear draw cache IDs so starting again doesn't try to retrieve
- // DrawCommands
- this.wrappedRendererData.entities.forEach((entity: Entity) => {
- entity.visuals.cacheId = null;
- });
- }
-
- start(firstStart = false) {
- if (this.isStarted) return;
- this.isStarted = true;
-
- if (!firstStart) {
- // As listeners were previously removed, flush some tracked inputs to
- // avoid bugs like the pointer being stuck down
- this.inputTracker.flushMidInput();
-
- this.forgetEntityCaches();
- }
-
- // Creating the WrappedRenderer already involves REGL. Losing WebGL context
- // requires repeating this step (ie, with each start())
- let wrappedRenderer: WrappedRenderer = makeWrappedRenderer(this.canvas);
-
- if (firstStart) this.addWebGlListeners();
- this.inputTracker.addListeners();
-
- let frameCallback: FrameRequestCallback = (
- _timestamp: DOMHighResTimeStamp,
- ) => {
- this.inputTracker.respondToInput();
-
- if (this.inputTracker.frameDirty) {
- console.debug(`>>> Frame for #${this.componentNumber}`);
-
- wrappedRenderer(this.wrappedRendererData);
- this.inputTracker.frameDirty = false;
- }
-
- this.currentRequestId = window.requestAnimationFrame(frameCallback);
- };
- if (!firstStart) {
- // Force draw upon restarting, eg after recovering from context loss
- this.inputTracker.frameDirty = true;
- }
- this.currentRequestId = window.requestAnimationFrame(frameCallback);
- }
-
- stop(lastStop = false) {
- if (this.currentRequestId !== null) {
- window.cancelAnimationFrame(this.currentRequestId);
- this.currentRequestId = null;
- }
-
- this.inputTracker.removeListeners();
- if (lastStop) this.webGlListenerTracker.removeListeners();
-
- this.isStarted = false;
- }
-}
+/* [Imports] */
+import InputTracker from './input_tracker.js';
+import {
+ cloneCameraState,
+ makeWrappedRenderer,
+ makeWrappedRendererData,
+} from './jscad/renderer.js';
+import type {
+ Entity,
+ PerspectiveCameraState,
+ WrappedRenderer,
+ WrappedRendererData,
+} from './jscad/types.js';
+import ListenerTracker from './listener_tracker.js';
+import type { RenderGroup } from './utilities.js';
+
+/* [Exports] */
+export default class StatefulRenderer {
+ private isStarted: boolean = false;
+ private currentRequestId: number | null = null;
+
+ private cameraState: PerspectiveCameraState = cloneCameraState();
+
+ private webGlListenerTracker: ListenerTracker;
+
+ private wrappedRendererData: WrappedRendererData;
+
+ private inputTracker: InputTracker;
+
+ constructor(
+ private canvas: HTMLCanvasElement,
+ renderGroup: RenderGroup,
+ private componentNumber: number,
+
+ private loseCallback: Function,
+ private restoreCallback: Function,
+ ) {
+ this.cameraState.position = [1000, 1000, 1500];
+
+ this.webGlListenerTracker = new ListenerTracker(canvas);
+
+ this.wrappedRendererData = makeWrappedRendererData(
+ renderGroup,
+ this.cameraState,
+ );
+
+ this.inputTracker = new InputTracker(
+ canvas,
+ this.cameraState,
+ this.wrappedRendererData.geometryEntities,
+ );
+ }
+
+ private addWebGlListeners() {
+ this.webGlListenerTracker.addListener(
+ 'webglcontextlost',
+ (contextEvent: WebGLContextEvent) => {
+ // Allow restoration of context
+ contextEvent.preventDefault();
+
+ console.debug(`>>> CONTEXT LOST FOR #${this.componentNumber}`);
+
+ this.loseCallback();
+
+ this.stop();
+ },
+ );
+
+ this.webGlListenerTracker.addListener(
+ 'webglcontextrestored',
+ (_contextEvent: WebGLContextEvent) => {
+ console.debug(`>>> CONTEXT RESTORED FOR #${this.componentNumber}`);
+
+ this.start();
+
+ this.restoreCallback();
+ },
+ );
+ }
+
+ private forgetEntityCaches() {
+ // Clear draw cache IDs so starting again doesn't try to retrieve
+ // DrawCommands
+ this.wrappedRendererData.entities.forEach((entity: Entity) => {
+ entity.visuals.cacheId = null;
+ });
+ }
+
+ start(firstStart = false) {
+ if (this.isStarted) return;
+ this.isStarted = true;
+
+ if (!firstStart) {
+ // As listeners were previously removed, flush some tracked inputs to
+ // avoid bugs like the pointer being stuck down
+ this.inputTracker.flushMidInput();
+
+ this.forgetEntityCaches();
+ }
+
+ // Creating the WrappedRenderer already involves REGL. Losing WebGL context
+ // requires repeating this step (ie, with each start())
+ let wrappedRenderer: WrappedRenderer = makeWrappedRenderer(this.canvas);
+
+ if (firstStart) this.addWebGlListeners();
+ this.inputTracker.addListeners();
+
+ let frameCallback: FrameRequestCallback = (
+ _timestamp: DOMHighResTimeStamp,
+ ) => {
+ this.inputTracker.respondToInput();
+
+ if (this.inputTracker.frameDirty) {
+ console.debug(`>>> Frame for #${this.componentNumber}`);
+
+ wrappedRenderer(this.wrappedRendererData);
+ this.inputTracker.frameDirty = false;
+ }
+
+ this.currentRequestId = window.requestAnimationFrame(frameCallback);
+ };
+ if (!firstStart) {
+ // Force draw upon restarting, eg after recovering from context loss
+ this.inputTracker.frameDirty = true;
+ }
+ this.currentRequestId = window.requestAnimationFrame(frameCallback);
+ }
+
+ stop(lastStop = false) {
+ if (this.currentRequestId !== null) {
+ window.cancelAnimationFrame(this.currentRequestId);
+ this.currentRequestId = null;
+ }
+
+ this.inputTracker.removeListeners();
+ if (lastStop) this.webGlListenerTracker.removeListeners();
+
+ this.isStarted = false;
+ }
+}
diff --git a/src/bundles/csg/types.ts b/src/bundles/csg/types.ts
index e813ea89a..b2cf0d266 100644
--- a/src/bundles/csg/types.ts
+++ b/src/bundles/csg/types.ts
@@ -1,349 +1,349 @@
-/* [Imports] */
-import type { RGB, RGBA } from '@jscad/modeling/src/colors';
-import type { Geom3 } from '@jscad/modeling/src/geometries/types';
-import {
- cameras,
- controls as _controls,
- type drawCommands,
-} from '@jscad/regl-renderer';
-import type makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi';
-import type { InitializationOptions } from 'regl';
-
-/* [Main] */
-let orthographicCamera = cameras.orthographic;
-let perspectiveCamera = cameras.perspective;
-
-let controls = _controls.orbit;
-
-/* [Exports] */
-
-// [Proper typing for JS in regl-renderer]
-type Numbers2 = [number, number];
-
-type Numbers3 = [number, number, number];
-export type VectorXYZ = Numbers3;
-export type CoordinatesXYZ = Numbers3;
-export type Color = RGB;
-
-export type Mat4 = Float32Array;
-
-// @jscad\regl-renderer\src\cameras\perspectiveCamera.js
-// @jscad\regl-renderer\src\cameras\orthographicCamera.js
-export type PerspectiveCamera = typeof perspectiveCamera;
-export type OrthographicCamera = typeof orthographicCamera;
-
-export type PerspectiveCameraState = Omit<
- typeof perspectiveCamera.cameraState,
-'target' | 'position' | 'view'
-> & {
- target: CoordinatesXYZ;
-
- position: CoordinatesXYZ;
- view: Mat4;
-};
-export type OrthographicCameraState = typeof orthographicCamera.cameraState;
-export type CameraState = PerspectiveCameraState | OrthographicCameraState;
-
-// @jscad\regl-renderer\src\controls\orbitControls.js
-export type Controls = Omit<
- typeof controls,
-'update' | 'zoomToFit' | 'rotate' | 'pan'
-> & {
- update: ControlsUpdate.Function;
- zoomToFit: ControlsZoomToFit.Function;
- rotate: ControlsRotate;
- pan: ControlsPan;
-};
-export namespace ControlsUpdate {
- export type Function = (options: Options) => Output;
-
- export type Options = {
- controls: ControlsState;
- camera: CameraState;
- };
-
- export type Output = {
- controls: {
- thetaDelta: number;
- phiDelta: number;
- scale: number;
- changed: boolean;
- };
- camera: {
- position: CoordinatesXYZ;
- view: Mat4;
- };
- };
-}
-export namespace ControlsZoomToFit {
- export type Function = (options: Options) => Output;
-
- export type Options = {
- controls: ControlsState;
- camera: CameraState;
- entities: GeometryEntity[];
- };
-
- export type Output = {
- camera: {
- target: VectorXYZ;
- };
- controls: {
- scale: number;
- };
- };
-}
-export type ControlsRotate = (
- options: {
- controls: ControlsState;
- camera: CameraState;
- speed?: number;
- },
- rotateAngles: Numbers2
-) => {
- controls: {
- thetaDelta: number;
- phiDelta: number;
- };
- camera: CameraState;
-};
-export type ControlsPan = (
- options: {
- controls: ControlsState;
- camera: CameraState;
- speed?: number;
- },
- rotateAngles: Numbers2
-) => {
- controls: ControlsState;
- camera: {
- position: CoordinatesXYZ;
- target: VectorXYZ;
- };
-};
-
-export type ControlsState = Omit<
- typeof controls.controlsState,
-'scale' | 'thetaDelta' | 'phiDelta'
-> &
- typeof controls.controlsProps & {
- scale: number;
-
- thetaDelta: number;
- phiDelta: number;
-};
-
-export type Solid = Geom3;
-
-// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js
-// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js
-export type Geometry = {
- type: '2d' | '3d';
- positions: CoordinatesXYZ[];
- normals: CoordinatesXYZ[];
- indices: CoordinatesXYZ[];
- colors: RGBA[];
- transforms: Mat4;
- isTransparent: boolean;
-};
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-// @jscad\regl-renderer\demo-web.js
-// There are still other Props used for uniforms in the various rendering
-// commands, eg model, color, angle
-export type Entity = {
- visuals: {
- // Key for the draw command that should be used on this Entity.
- // Key is used on WrappedRenderer.AllData#drawCommands.
- // Property must exist & match a drawCommand,
- // or behaviour is like show: false
- drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh';
-
- // Whether to actually draw the Entity via nested DrawCommand
- show: boolean;
- };
-};
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-export type GeometryEntity = Entity & {
- visuals: {
- drawCmd: 'drawLines' | 'drawMesh';
-
- // Whether the Geometry is transparent.
- // Transparents need to be rendered before non-transparents
- transparent: boolean;
-
- // Eventually determines whether to use vColorShaders
- // (Geometry must also have colour) or meshShaders
- useVertexColors: boolean;
- };
-
- // The original Geometry used to make the GeometryEntity
- geometry: Geometry;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js
-// @jscad\regl-renderer\demo-web.js
-export type AxisEntityType = Entity & {
- visuals: {
- drawCmd: 'drawAxis';
- };
-
- xColor?: RGBA;
- yColor?: RGBA;
- zColor?: RGBA;
- size?: number;
- alwaysVisible?: boolean;
-
- // Deprecated
- lineWidth?: number;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js
-// @jscad\regl-renderer\demo-web.js
-export type GridEntity = Entity & {
- visuals: {
- drawCmd: 'drawGrid';
-
- color?: RGBA;
- fadeOut?: boolean;
- };
- size?: Numbers2;
- ticks?: number;
- centered?: boolean;
-
- // Deprecated
- lineWidth?: number;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js
-// @jscad\regl-renderer\demo-web.js
-// @jscad\web\src\ui\views\viewer.js
-// @jscad\regl-renderer\src\index.js
-export type MultiGridEntityType = Omit & {
- // Entity#visuals gets stuffed into the nested DrawCommand as Props.
- // The Props get passed on wholesale by makeDrawMultiGrid()'s returned lambda,
- // where the following properties then get used
- // (rather than while setting up the DrawCommands)
- visuals: {
- subColor?: RGBA; // As color
- };
-
- // First number used on the main grid, second number on sub grid
- ticks?: [number, number];
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawLines\index.js
-export type LinesEntity = Entity & {
- visuals: {
- drawCmd: 'drawLines';
- };
-
- color?: RGBA;
-};
-
-// @jscad\regl-renderer\src\rendering\commands\drawMesh\index.js
-export type MeshEntity = Entity & {
- visuals: {
- drawCmd: 'drawMesh';
- };
-
- dynamicCulling?: boolean;
- color?: RGBA;
-};
-
-export namespace PrepareRender {
- // @jscad\regl-renderer\src\rendering\render.js
- export type Function = (options: AllOptions) => WrappedRenderer.Function;
-
- // @jscad\regl-renderer\src\rendering\render.js
- export type AllOptions = {
- // Used to initialise Regl from the REGL package constructor
- glOptions: InitializationOptions;
- };
-}
-
-// When called, the WrappedRenderer creates a main DrawCommand.
-// This main DrawCommand then gets called as a scoped command,
-// used to create & call more DrawCommands for the #entities.
-// Nested DrawCommands get cached
-// & may store some Entity properties during setup,
-// but properties passed in from Props later may take precedence.
-// The main DrawCommand is said to be in charge of injecting most uniforms into
-// the Regl context, ie keeping track of all Regl global state
-export namespace WrappedRenderer {
- // @jscad\regl-renderer\src\rendering\render.js
- export type Function = (data: AllData) => void;
-
- // @jscad\regl-renderer\src\rendering\render.js
- // Gets used in the WrappedRenderer.
- // Also gets passed as Props into the main DrawCommand,
- // where it is used in setup specified by the internal
- // renderContext.js/renderWrapper.
- // The lambda of the main DrawCommand does not use those Props, rather,
- // it references the data in the WrappedRenderer directly.
- // Therefore, regl.prop() is not called,
- // nor are Props used via the semantic equivalent (context, props) => {}.
- // The context passed to that lambda also remains unused
- export type AllData = {
- rendering?: RenderOptions;
-
- entities: Entity[];
-
- drawCommands: PrepareDrawCommands;
-
- // Along with all of the relevant Entity's & Entity#visuals's properties,
- // this gets stuffed into each nested DrawCommand as Props.
- // Messy & needs tidying in regl-renderer
- camera: CameraState;
- };
-
- // @jscad\regl-renderer\src\rendering\renderDefaults.js
- export type RenderOptions = {
- // Custom value used early on in render.js.
- // Clears the canvas to this background colour
- background?: RGBA;
-
- // Default value used directly in V2's entitiesFromSolids.js as the default Geometry colour.
- // Default value also used directly in various rendering commands as their shader uniforms' default colour.
- // Custom value appears unused
- meshColor?: RGBA;
-
- // Custom value used in various rendering commands as shader uniforms
- lightColor?: RGBA;
- lightDirection?: VectorXYZ;
- ambientLightAmount?: number;
- diffuseLightAmount?: number;
- specularLightAmount?: number;
- materialShininess?: number; // As uMaterialShininess in main DrawCommand
-
- // Unused
- lightPosition?: CoordinatesXYZ; // See also lightDirection
- };
-
- // There are 4 rendering commands to use in regl-renderer:
- // drawAxis, drawGrid, drawLines & drawMesh.
- // drawExps appears abandoned.
- // Only once passed Regl & an Entity do they return an actual DrawCommand
- export type PrepareDrawCommands = Record;
- export type PrepareDrawCommandFunction =
- | typeof drawCommands
- | typeof makeDrawMultiGrid;
-}
-
-// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
-// Converts Solids into Geometries and then into Entities
-export namespace EntitiesFromSolids {
- export type Function = (
- options?: Options,
- ...solids: Solid[]
- ) => GeometryEntity[];
-
- export type Options = {
- // Default colour for entity rendering if the solid does not have one
- color?: RGBA;
-
- // Whether to smooth the normals of 3D solids, rendering a smooth surface
- smoothNormals?: boolean;
- };
-}
+/* [Imports] */
+import type { RGB, RGBA } from '@jscad/modeling/src/colors';
+import type { Geom3 } from '@jscad/modeling/src/geometries/types';
+import {
+ cameras,
+ controls as _controls,
+ type drawCommands,
+} from '@jscad/regl-renderer';
+import type makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi';
+import type { InitializationOptions } from 'regl';
+
+/* [Main] */
+let orthographicCamera = cameras.orthographic;
+let perspectiveCamera = cameras.perspective;
+
+let controls = _controls.orbit;
+
+/* [Exports] */
+
+// [Proper typing for JS in regl-renderer]
+type Numbers2 = [number, number];
+
+type Numbers3 = [number, number, number];
+export type VectorXYZ = Numbers3;
+export type CoordinatesXYZ = Numbers3;
+export type Color = RGB;
+
+export type Mat4 = Float32Array;
+
+// @jscad\regl-renderer\src\cameras\perspectiveCamera.js
+// @jscad\regl-renderer\src\cameras\orthographicCamera.js
+export type PerspectiveCamera = typeof perspectiveCamera;
+export type OrthographicCamera = typeof orthographicCamera;
+
+export type PerspectiveCameraState = Omit<
+ typeof perspectiveCamera.cameraState,
+'target' | 'position' | 'view'
+> & {
+ target: CoordinatesXYZ;
+
+ position: CoordinatesXYZ;
+ view: Mat4;
+};
+export type OrthographicCameraState = typeof orthographicCamera.cameraState;
+export type CameraState = PerspectiveCameraState | OrthographicCameraState;
+
+// @jscad\regl-renderer\src\controls\orbitControls.js
+export type Controls = Omit<
+ typeof controls,
+'update' | 'zoomToFit' | 'rotate' | 'pan'
+> & {
+ update: ControlsUpdate.Function;
+ zoomToFit: ControlsZoomToFit.Function;
+ rotate: ControlsRotate;
+ pan: ControlsPan;
+};
+export namespace ControlsUpdate {
+ export type Function = (options: Options) => Output;
+
+ export type Options = {
+ controls: ControlsState;
+ camera: CameraState;
+ };
+
+ export type Output = {
+ controls: {
+ thetaDelta: number;
+ phiDelta: number;
+ scale: number;
+ changed: boolean;
+ };
+ camera: {
+ position: CoordinatesXYZ;
+ view: Mat4;
+ };
+ };
+}
+export namespace ControlsZoomToFit {
+ export type Function = (options: Options) => Output;
+
+ export type Options = {
+ controls: ControlsState;
+ camera: CameraState;
+ entities: GeometryEntity[];
+ };
+
+ export type Output = {
+ camera: {
+ target: VectorXYZ;
+ };
+ controls: {
+ scale: number;
+ };
+ };
+}
+export type ControlsRotate = (
+ options: {
+ controls: ControlsState;
+ camera: CameraState;
+ speed?: number;
+ },
+ rotateAngles: Numbers2
+) => {
+ controls: {
+ thetaDelta: number;
+ phiDelta: number;
+ };
+ camera: CameraState;
+};
+export type ControlsPan = (
+ options: {
+ controls: ControlsState;
+ camera: CameraState;
+ speed?: number;
+ },
+ rotateAngles: Numbers2
+) => {
+ controls: ControlsState;
+ camera: {
+ position: CoordinatesXYZ;
+ target: VectorXYZ;
+ };
+};
+
+export type ControlsState = Omit<
+ typeof controls.controlsState,
+'scale' | 'thetaDelta' | 'phiDelta'
+> &
+ typeof controls.controlsProps & {
+ scale: number;
+
+ thetaDelta: number;
+ phiDelta: number;
+};
+
+export type Solid = Geom3;
+
+// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js
+// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js
+export type Geometry = {
+ type: '2d' | '3d';
+ positions: CoordinatesXYZ[];
+ normals: CoordinatesXYZ[];
+ indices: CoordinatesXYZ[];
+ colors: RGBA[];
+ transforms: Mat4;
+ isTransparent: boolean;
+};
+
+// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
+// @jscad\regl-renderer\demo-web.js
+// There are still other Props used for uniforms in the various rendering
+// commands, eg model, color, angle
+export type Entity = {
+ visuals: {
+ // Key for the draw command that should be used on this Entity.
+ // Key is used on WrappedRenderer.AllData#drawCommands.
+ // Property must exist & match a drawCommand,
+ // or behaviour is like show: false
+ drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh';
+
+ // Whether to actually draw the Entity via nested DrawCommand
+ show: boolean;
+ };
+};
+
+// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js
+export type GeometryEntity = Entity & {
+ visuals: {
+ drawCmd: 'drawLines' | 'drawMesh';
+
+ // Whether the Geometry is transparent.
+ // Transparents need to be rendered before non-transparents
+ transparent: boolean;
+
+ // Eventually determines whether to use vColorShaders
+ // (Geometry must also have colour) or meshShaders
+ useVertexColors: boolean;
+ };
+
+ // The original Geometry used to make the GeometryEntity
+ geometry: Geometry;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js
+// @jscad\regl-renderer\demo-web.js
+export type AxisEntityType = Entity & {
+ visuals: {
+ drawCmd: 'drawAxis';
+ };
+
+ xColor?: RGBA;
+ yColor?: RGBA;
+ zColor?: RGBA;
+ size?: number;
+ alwaysVisible?: boolean;
+
+ // Deprecated
+ lineWidth?: number;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js
+// @jscad\regl-renderer\demo-web.js
+export type GridEntity = Entity & {
+ visuals: {
+ drawCmd: 'drawGrid';
+
+ color?: RGBA;
+ fadeOut?: boolean;
+ };
+ size?: Numbers2;
+ ticks?: number;
+ centered?: boolean;
+
+ // Deprecated
+ lineWidth?: number;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js
+// @jscad\regl-renderer\demo-web.js
+// @jscad\web\src\ui\views\viewer.js
+// @jscad\regl-renderer\src\index.js
+export type MultiGridEntityType = Omit & {
+ // Entity#visuals gets stuffed into the nested DrawCommand as Props.
+ // The Props get passed on wholesale by makeDrawMultiGrid()'s returned lambda,
+ // where the following properties then get used
+ // (rather than while setting up the DrawCommands)
+ visuals: {
+ subColor?: RGBA; // As color
+ };
+
+ // First number used on the main grid, second number on sub grid
+ ticks?: [number, number];
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawLines\index.js
+export type LinesEntity = Entity & {
+ visuals: {
+ drawCmd: 'drawLines';
+ };
+
+ color?: RGBA;
+};
+
+// @jscad\regl-renderer\src\rendering\commands\drawMesh\index.js
+export type MeshEntity = Entity & {
+ visuals: {
+ drawCmd: 'drawMesh';
+ };
+
+ dynamicCulling?: boolean;
+ color?: RGBA;
+};
+
+export namespace PrepareRender {
+ // @jscad\regl-renderer\src\rendering\render.js
+ export type Function = (options: AllOptions) => WrappedRenderer.Function;
+
+ // @jscad\regl-renderer\src\rendering\render.js
+ export type AllOptions = {
+ // Used to initialise Regl from the REGL package constructor
+ glOptions: InitializationOptions;
+ };
+}
+
+// When called, the WrappedRenderer creates a main DrawCommand.
+// This main DrawCommand then gets called as a scoped command,
+// used to create & call more DrawCommands for the #entities.
+// Nested DrawCommands get cached
+// & may store some Entity properties during setup,
+// but properties passed in from Props later may take precedence.
+// The main DrawCommand is said to be in charge of injecting most uniforms into
+// the Regl context, ie keeping track of all Regl global state
+export namespace WrappedRenderer {
+ // @jscad\regl-renderer\src\rendering\render.js
+ export type Function = (data: AllData) => void;
+
+ // @jscad\regl-renderer\src\rendering\render.js
+ // Gets used in the WrappedRenderer.
+ // Also gets passed as Props into the main DrawCommand,
+ // where it is used in setup specified by the internal
+ // renderContext.js/renderWrapper.
+ // The lambda of the main DrawCommand does not use those Props, rather,
+ // it references the data in the WrappedRenderer directly.
+ // Therefore, regl.prop() is not called,
+ // nor are Props used via the semantic equivalent (context, props) => {}.
+ // The context passed to that lambda also remains unused
+ export type AllData = {
+ rendering?: RenderOptions;
+
+ entities: Entity[];
+
+ drawCommands: PrepareDrawCommands;
+
+ // Along with all of the relevant Entity's & Entity#visuals's properties,
+ // this gets stuffed into each nested DrawCommand as Props.
+ // Messy & needs tidying in regl-renderer
+ camera: CameraState;
+ };
+
+ // @jscad\regl-renderer\src\rendering\renderDefaults.js
+ export type RenderOptions = {
+ // Custom value used early on in render.js.
+ // Clears the canvas to this background colour
+ background?: RGBA;
+
+ // Default value used directly in V2's entitiesFromSolids.js as the default Geometry colour.
+ // Default value also used directly in various rendering commands as their shader uniforms' default colour.
+ // Custom value appears unused
+ meshColor?: RGBA;
+
+ // Custom value used in various rendering commands as shader uniforms
+ lightColor?: RGBA;
+ lightDirection?: VectorXYZ;
+ ambientLightAmount?: number;
+ diffuseLightAmount?: number;
+ specularLightAmount?: number;
+ materialShininess?: number; // As uMaterialShininess in main DrawCommand
+
+ // Unused
+ lightPosition?: CoordinatesXYZ; // See also lightDirection
+ };
+
+ // There are 4 rendering commands to use in regl-renderer:
+ // drawAxis, drawGrid, drawLines & drawMesh.
+ // drawExps appears abandoned.
+ // Only once passed Regl & an Entity do they return an actual DrawCommand
+ export type PrepareDrawCommands = Record