diff --git a/README.md b/README.md
index 1b9f2104..46bf5f91 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,10 @@ bin/rails g
* [Tailored Select](./lib/generators/rolemodel/tailored_select)
* [Lograge](./lib/generators/rolemodel/lograge)
+### LightningCAD
+
+For LightningCAD apps there are [generators](./lib/generators/lightning_cad) to add the required setup
+
## Development
Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `npm install -g yarn`.
diff --git a/lib/generators/lightning_cad/README.md b/lib/generators/lightning_cad/README.md
new file mode 100644
index 00000000..90c799ae
--- /dev/null
+++ b/lib/generators/lightning_cad/README.md
@@ -0,0 +1,31 @@
+# LightningCAD Generator
+
+Run all the generators:
+
+```
+rails g lightning_cad:all
+```
+
+Or run them individually
+```
+rails g lightning_cad:install
+rails g lightning_cad:webpack
+rails g lightning_cad:test
+```
+
+## Depends On
+
+- `rolemodel:linters:eslint`
+- `rolemodel:optics:base`
+- `rolemodel:webpack`
+- `rolemodel:react`
+
+## What you get
+
+Pulls in [LightningCAD](https://github.com/RoleModel/lightning-cad) and sets up a demo editor
+
+This will add a route for `/editor` with a blank canvas.
+
+The main entrypoint into the JavaScript code is `/app/javascript/components/App.jsx`. In this file a project is created. You can manually add components like lines or shapes to that project.
+
+After running the generators additional setup is required see [how to setup a LightningCAD app](https://github.com/RoleModel/lightning-cad?tab=readme-ov-file#app-setup) for more details.
diff --git a/lib/generators/lightning_cad/USAGE b/lib/generators/lightning_cad/USAGE
new file mode 100644
index 00000000..a9f6e794
--- /dev/null
+++ b/lib/generators/lightning_cad/USAGE
@@ -0,0 +1,15 @@
+Description:
+ This generator requires JavaScript setup including webpack, react, and eslint
+
+ Install:
+ Installs required dependencies
+ Generates a demo editor
+
+ Webpack:
+ Modifies the webpack config to work better for LightningCAD apps
+
+ Test:
+ Installs jasmine and adds the test setup required for LightningCAD models
+
+Example:
+ bin/rails generate lightning-cad:all
diff --git a/lib/generators/lightning_cad/all_generator.rb b/lib/generators/lightning_cad/all_generator.rb
new file mode 100644
index 00000000..932ae15c
--- /dev/null
+++ b/lib/generators/lightning_cad/all_generator.rb
@@ -0,0 +1,13 @@
+require 'rails'
+
+module LightningCad
+ class AllGenerator < Rails::Generators::Base
+ source_root File.expand_path('./templates', __dir__)
+
+ def run_all_the_generators
+ generate 'lightning_cad:install'
+ generate 'lightning_cad:webpack'
+ generate 'lightning_cad:test'
+ end
+ end
+end
diff --git a/lib/generators/lightning_cad/install_generator.rb b/lib/generators/lightning_cad/install_generator.rb
new file mode 100644
index 00000000..e6ff216b
--- /dev/null
+++ b/lib/generators/lightning_cad/install_generator.rb
@@ -0,0 +1,81 @@
+require 'rails'
+
+module LightningCad
+ class InstallGenerator < Rails::Generators::Base
+ source_root File.expand_path('./templates', __dir__)
+
+ def install_yarn_dependencies
+ say 'Adding lightning-cad dependency'
+ copy_file '.npmrc', '.npmrc'
+
+ dependencies = %w[
+ @rolemodel/lightning-cad
+ @rolemodel/lightning-cad-ui
+ mobx-react@^6.1.5
+ mobx-utils@^5.5.2
+ mobx@^5.15.2
+ glob@^10.2.2
+ import-glob@1.5.0
+ react-router-dom@^5.0.1
+ react-popper@^1.3.7
+ classnames@^2.2.5
+ ]
+
+
+ run "yarn add #{dependencies.join(" ")}"
+ end
+
+ def create_basic_app
+ say "Creating React App Component"
+ insert_into_file 'app/javascript/controllers/react_controller.js', "import App from '../components/App.jsx'\n", before: "import HelloReact from '../components/HelloReact.jsx'\n"
+ insert_into_file 'app/javascript/controllers/react_controller.js', " App,\n", after: "const registeredComponents = {\n"
+ copy_file 'app/javascript/components/MaterialIcon.jsx', 'app/javascript/components/MaterialIcon.jsx'
+ copy_file 'app/javascript/components/LocalIconFactory.jsx', 'app/javascript/components/LocalIconFactory.jsx'
+ copy_file 'app/javascript/components/App.jsx', 'app/javascript/components/App.jsx'
+ end
+
+ def add_stylesheets
+ stylesheets = <<~CSS
+ @import '@rolemodel/lightning-cad-ui/scss/lightning-cad.scss';
+ CSS
+
+ prepend_to_file 'app/assets/stylesheets/application.scss', stylesheets
+ end
+
+ def global_configuration
+ copy_file '.eslintrc.js', '.eslintrc.js'
+ end
+
+ def create_controller
+ say "Creating Rails controller and view"
+ template 'app/views/layouts/editor.html.slim'
+ copy_file 'app/controllers/editor_controller.rb', 'app/controllers/editor_controller.rb'
+ copy_file 'app/views/editor/editor.html.slim', 'app/views/editor/editor.html.slim'
+ route "get '/editor/*all', to: 'editor#editor'"
+ route "get :editor, to: 'editor#editor'"
+ end
+
+ def add_javascript_initializers
+ say "Adding JavaScript initializers"
+ initializer_setup = <<~JS
+ import './config/initializers/**/*.js'
+ JS
+ append_to_file 'app/javascript/application.js', initializer_setup
+ copy_file 'app/javascript/config/initializers/smartJSON.js', 'app/javascript/config/initializers/smartJSON.js'
+ copy_file 'app/javascript/helpers/hooks.js', 'app/javascript/helpers/hooks.js'
+ copy_file 'app/javascript/helpers/register_hooks.js', 'app/javascript/helpers/register_hooks.js'
+ # To prevent smartJSON from throwing an error
+ run 'mkdir app/javascript/shared'
+ run 'mkdir app/javascript/shared/domain-models'
+ run 'touch app/javascript/shared/domain-models/.keep'
+ end
+
+ def setup_chrome_cad
+ chrome_cad_setup = <<~JS
+ import { ChromeCADEventEmitter } from '@rolemodel/lightning-cad/drawing-editor'
+ window.__LCAD_CHROME_CAD_EVENT_EMITTER = new ChromeCADEventEmitter()
+ JS
+ append_to_file 'app/javascript/application.js', chrome_cad_setup
+ end
+ end
+end
diff --git a/lib/generators/lightning_cad/templates/.eslintrc.js b/lib/generators/lightning_cad/templates/.eslintrc.js
new file mode 100644
index 00000000..f0f5e0a8
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/.eslintrc.js
@@ -0,0 +1,123 @@
+export default {
+ root: true,
+ env: {
+ browser: true,
+ node: true,
+ es6: true,
+ },
+ plugins: ["react"],
+ extends: ["eslint:recommended", "plugin:react/recommended"],
+ parser: "babel-eslint",
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 2017,
+ },
+ rules: {
+ "accessor-pairs": "warn",
+ "arrow-body-style": "warn",
+ "arrow-parens": ["warn", "as-needed"],
+ "arrow-spacing": "warn",
+ "comma-dangle": ["warn", "only-multiline"],
+ "comma-style": ["warn", "last"],
+ "computed-property-spacing": ["warn", "never"],
+ "consistent-this": "warn",
+ curly: ["warn", "multi-line"],
+ "default-case": "warn",
+ "dot-location": ["warn", "property"],
+ eqeqeq: "warn",
+ "generator-star-spacing": "warn",
+ "id-blacklist": "warn",
+ "id-match": "warn",
+ "jsx-quotes": "warn",
+ "linebreak-style": ["warn", "unix"],
+ "max-nested-callbacks": "warn",
+ "no-array-constructor": "warn",
+ "no-caller": "warn",
+ "no-catch-shadow": "warn",
+ "no-console": "off",
+ "no-continue": "warn",
+ "no-div-regex": "warn",
+ "no-duplicate-imports": "warn",
+ "no-extra-label": "warn",
+ "no-extra-semi": "warn",
+ "no-floating-decimal": "warn",
+ "no-implicit-coercion": [
+ "warn",
+ {
+ boolean: false,
+ number: false,
+ string: false,
+ },
+ ],
+ "no-implied-eval": "warn",
+ "no-inner-declarations": ["warn", "functions"],
+ "no-invalid-this": "warn",
+ "no-iterator": "warn",
+ "no-label-var": "warn",
+ "no-labels": "warn",
+ "no-lone-blocks": "warn",
+ "no-mixed-requires": "warn",
+ "no-mixed-spaces-and-tabs": "warn",
+ "no-multi-str": "warn",
+ "no-new": "warn",
+ "no-new-func": "warn",
+ "no-new-object": "warn",
+ "no-new-require": "warn",
+ "no-new-wrappers": "warn",
+ "no-octal-escape": "warn",
+ "no-process-exit": "warn",
+ "no-proto": "warn",
+ "no-redeclare": "warn",
+ "no-restricted-globals": "warn",
+ "no-restricted-imports": "warn",
+ "no-restricted-modules": "warn",
+ "no-restricted-syntax": "warn",
+ "no-script-url": "warn",
+ "no-self-compare": "warn",
+ "no-shadow-restricted-names": "warn",
+ "no-spaced-func": "warn",
+ "no-undef": "warn",
+ "no-undef-init": "warn",
+ "no-unexpected-multiline": "warn",
+ "no-unmodified-loop-condition": "warn",
+ "no-unneeded-ternary": [
+ "warn",
+ {
+ defaultAssignment: true,
+ },
+ ],
+ "no-unreachable": "warn",
+ "no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_", // Allow unused arguments starting with an underscore
+ varsIgnorePattern: "^_", // Allow unused variables starting with an underscore
+ },
+ ],
+ "no-useless-call": "warn",
+ "no-useless-concat": "warn",
+ "no-useless-constructor": "warn",
+ "no-useless-escape": "warn",
+ "no-var": "warn",
+ "no-void": "warn",
+ "no-whitespace-before-property": "warn",
+ "no-with": "warn",
+ "prefer-arrow-callback": "warn",
+ "prefer-const": "warn",
+ "prefer-template": "warn",
+ "require-yield": "warn",
+ semi: ["warn", "never"],
+ "sort-imports": "warn",
+ "template-curly-spacing": "warn",
+ "wrap-regex": "warn",
+ "yield-star-spacing": "warn",
+ yoda: ["warn", "never"],
+ },
+ settings: {
+ react: {
+ version: "16.6.0",
+ },
+ },
+};
diff --git a/lib/generators/lightning_cad/templates/.npmrc b/lib/generators/lightning_cad/templates/.npmrc
new file mode 100644
index 00000000..6376c0b3
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/.npmrc
@@ -0,0 +1,2 @@
+@rolemodel:registry=https://npm.pkg.github.com
+//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}
diff --git a/lib/generators/lightning_cad/templates/app/controllers/editor_controller.rb b/lib/generators/lightning_cad/templates/app/controllers/editor_controller.rb
new file mode 100644
index 00000000..b9d616bd
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/controllers/editor_controller.rb
@@ -0,0 +1,5 @@
+class EditorController < ApplicationController
+ def editor
+ render layout: 'editor'
+ end
+end
diff --git a/lib/generators/lightning_cad/templates/app/javascript/components/App.jsx b/lib/generators/lightning_cad/templates/app/javascript/components/App.jsx
new file mode 100644
index 00000000..0b246c1d
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/components/App.jsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import { createBrowserHistory } from 'history'
+import PropTypes from 'prop-types'
+
+import {
+ Icon,
+ IconFactoryContext,
+ MultiPerspectiveProjectEditorView,
+} from '@rolemodel/lightning-cad-ui'
+import { Router } from 'react-router-dom'
+import LocalIconFactory from './LocalIconFactory.jsx'
+
+import {
+ DrawingEditor,
+ VersionedProject,
+ Project
+} from '@rolemodel/lightning-cad/drawing-editor'
+
+export default class App extends React.Component {
+ static propTypes = {
+ basePath: PropTypes.string,
+ backPath: PropTypes.string
+ }
+
+ static defaultProps = {
+ basePath: '/',
+ backPath: '/'
+ }
+
+ constructor(props) {
+ super(props)
+ this._modalRoot = document.getElementById('modal_root') || document.createElement('div')
+
+ this._project = new VersionedProject(new Project())
+
+ const top = new DrawingEditor(this._project)
+ top.toolPalette()
+ this._drawingEditors = { top }
+ }
+
+ modalRoot() { return this._modalRoot }
+
+ history() {
+ if (!this._history) {
+ this._history = createBrowserHistory({ basename: this.props.basePath })
+ }
+ return this._history
+ }
+
+ iconFactory() {
+ return this._iconFactory ??= new LocalIconFactory()
+ }
+
+ render() {
+ return (
+
+
+ (
+
+
+ Back
+
+ ),
+ },
+ },
+ }}
+ />
+
+
+ )
+ }
+}
diff --git a/lib/generators/lightning_cad/templates/app/javascript/components/LocalIconFactory.jsx b/lib/generators/lightning_cad/templates/app/javascript/components/LocalIconFactory.jsx
new file mode 100644
index 00000000..b45c6828
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/components/LocalIconFactory.jsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import classnames from 'classnames'
+
+import { IconFactory } from '@rolemodel/lightning-cad-ui'
+
+import MaterialIcon from './MaterialIcon.jsx'
+
+const customIcons = {}
+
+export default class LocalIconFactory extends IconFactory {
+ constructor(props) {
+ super(props);
+
+ this._iconNameAlias = {
+ KeyboardArrowLeft: "keyboard_arrow_left",
+ KeyboardArrowRight: "keyboard_arrow_right",
+ KeyboardArrowDown: "keyboard_arrow_down",
+ ArrowBack: "keyboard_backspace",
+ Lock: "lock",
+ LockOpen: "lock_open",
+ Visibility: "visibility",
+ VisibilityOff: "visibility_off",
+ undo: "arrow_back",
+ redo: "arrow_forward",
+ };
+ }
+
+ makeIcon(name, otherProps) {
+ const iconName = this._iconNameAlias[name] || name;
+
+ const iconProps = {
+ className: otherProps.className,
+ title: otherProps.hoverText || otherProps.name,
+ };
+
+ if (iconName in customIcons) {
+ return this._customIcon(iconName, iconProps);
+ }
+
+ return ;
+ }
+
+ /* eslint-disable react/no-danger */
+ _customIcon(iconName, { className, ...otherProps }) {
+ // Setting innerHTML is not dangerous with these SVG files since we created
+ // them and they are part of this app's code.
+ return (
+
+ );
+ }
+ /* eslint-enable react/no-danger */
+}
diff --git a/lib/generators/lightning_cad/templates/app/javascript/components/MaterialIcon.jsx b/lib/generators/lightning_cad/templates/app/javascript/components/MaterialIcon.jsx
new file mode 100644
index 00000000..7b934742
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/components/MaterialIcon.jsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import classnames from 'classnames'
+import * as PropTypes from 'prop-types'
+
+function MaterialIcon({ iconName, iconProps: { className, ...otherProps } }) {
+ return (
+
+ {iconName}
+
+ )
+}
+
+MaterialIcon.propTypes = {
+ iconName: PropTypes.string,
+ iconProps: PropTypes.shape({
+ className: PropTypes.string,
+ title: PropTypes.string,
+ hoverText: PropTypes.string,
+ name: PropTypes.string
+ })
+}
+
+export default MaterialIcon
diff --git a/lib/generators/lightning_cad/templates/app/javascript/config/initializers/smartJSON.js b/lib/generators/lightning_cad/templates/app/javascript/config/initializers/smartJSON.js
new file mode 100644
index 00000000..ea6106de
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/config/initializers/smartJSON.js
@@ -0,0 +1,38 @@
+import { SmartObjectBuilder } from '@rolemodel/lightning-cad/smartJSON'
+
+let domainModelModules = []
+if (import.meta.webpackContext) {
+ const domainModelsContext = await import.meta.webpackContext(
+ '../../shared/domain-models',
+ {
+ recursive: true,
+ regExp: /.js$/,
+ }
+ )
+ // Usually browser
+ domainModelModules = domainModelsContext.keys().map(domainModelsContext)
+} else {
+ // Node/test environment
+ const path = await import('path')
+ const glob = await import('glob')
+ const url = await import('url')
+
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
+ const files = glob.sync('**/*.js', {
+ cwd: path.resolve(path.join(__dirname, '../../shared/domain-models'))
+ })
+
+ domainModelModules = []
+
+ await Promise.all(
+ files.map(async (file) => {
+ domainModelModules.push(await import(`../../shared/domain-models/${file}`))
+ })
+ )
+}
+
+const domainModels = domainModelModules.map(({ default: module }) => module)
+
+SmartObjectBuilder.configure((config) => {
+ config.classes.addClasses(...domainModels)
+})
diff --git a/lib/generators/lightning_cad/templates/app/javascript/helpers/hooks.js b/lib/generators/lightning_cad/templates/app/javascript/helpers/hooks.js
new file mode 100644
index 00000000..78bd2307
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/helpers/hooks.js
@@ -0,0 +1,84 @@
+import path from 'node:path'
+import * as glob from 'glob'
+import filterFile from '../../../config/webpack/loaders/filter-file.js'
+
+const shorthandRoot = path.join(process.cwd(), 'app/javascript')
+const config = {
+ shorthandRoot
+}
+
+export function resolve(specifier, context, nextResolve) {
+ let finalSpecifier = specifier
+ if (specifier.startsWith('#')) {
+ finalSpecifier = resolveShorthandPath(finalSpecifier, context, nextResolve)
+ }
+
+ if (specifier.includes('*')) {
+ return resolveGlobPath(finalSpecifier, context, nextResolve)
+ }
+
+ return nextResolve(finalSpecifier, context)
+}
+
+export async function load(specifier, context, nextLoad) {
+ if (!specifier.includes('*')) {
+ const result = await nextLoad(specifier, context)
+ if (!result.source) return result
+
+ const source = filterFile(result.source, 'server')
+
+ return {
+ ...result,
+ source
+ }
+ }
+
+ const pattern = specifier.replace('file://', '')
+ const paths = glob.globSync(pattern, { nodir: true })
+
+ const pathParts = pattern.split('/')
+ const basePathParts = pathParts.slice(0, pathParts.findIndex(part => part.includes('*')))
+ // we need to determine how many times we have a "*" in a folder level. The imports have to go back that far in order to work properly.
+ const folderLevels = pathParts.length - basePathParts.length - 1 // minus one for the file
+ const basePath = basePathParts.join('/')
+ const baseImportPath = `${Array(folderLevels).fill('../').join('')}`
+
+ const getModuleIdentifier = index => `module${index}`
+ const getImportPath = file => path.join(baseImportPath, path.relative(basePath, file))
+ const importStatements = paths.map((file, index) => {
+ return `import * as ${getModuleIdentifier(index)} from './${getImportPath(file)}'`
+ })
+ const exportStatement = `export default [${paths.map((_s, index) => getModuleIdentifier(index)).join(', ') }]`
+
+ const content = [...importStatements, exportStatement].join('\n')
+ const source = Buffer.from(content)
+
+ return {
+ format: "module",
+ source,
+ shortCircuit: true
+ }
+}
+
+function resolveShorthandPath(specifier, _context, _nextResolve) {
+ return path.join(config.shorthandRoot, specifier.slice(1))
+}
+
+function resolveGlobPath(specifier, context, _nextResolve) {
+ // We have to manually resolve the path so that we can load all the files that match the pattern later
+ let finalPath
+ if (specifier.startsWith('.')) { // Relative path to parent
+ const basePath = context.parentURL.replace('file://', '').split('/').slice(0, -1).join('/')
+ finalPath = path.join(basePath, specifier)
+ } else if (specifier.startsWith('/')) { // Absolute path
+ finalPath = specifier
+ } else { // node modules
+ finalPath = path.join(process.cwd(), 'node_modules', specifier)
+ }
+
+ return {
+ url: `file://${finalPath}`,
+ type: "module",
+ shortCircuit: true
+ }
+}
diff --git a/lib/generators/lightning_cad/templates/app/javascript/helpers/register_hooks.js b/lib/generators/lightning_cad/templates/app/javascript/helpers/register_hooks.js
new file mode 100644
index 00000000..bf581f21
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/javascript/helpers/register_hooks.js
@@ -0,0 +1,3 @@
+import { register } from 'node:module'
+
+register('./hooks.js', import.meta.url)
diff --git a/lib/generators/lightning_cad/templates/app/views/editor/editor.html.slim b/lib/generators/lightning_cad/templates/app/views/editor/editor.html.slim
new file mode 100644
index 00000000..3297a591
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/views/editor/editor.html.slim
@@ -0,0 +1,2 @@
+= react_component 'App', basePath: editor_path
+#modal_root
diff --git a/lib/generators/lightning_cad/templates/app/views/layouts/editor.html.slim.tt b/lib/generators/lightning_cad/templates/app/views/layouts/editor.html.slim.tt
new file mode 100644
index 00000000..a6afe5d7
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/app/views/layouts/editor.html.slim.tt
@@ -0,0 +1,11 @@
+doctype html
+html
+ head
+ title <%= Rails.application.class.name.deconstantize.titleize %>
+ = csrf_meta_tags
+ = csp_meta_tag
+ = stylesheet_link_tag 'application', 'data-turbo-track': 'reload'
+ = javascript_include_tag 'application', 'data-turbo-track': 'reload', defer: true
+ meta content="width=device-width, initial-scale=1" name="viewport"
+ body
+ = yield
diff --git a/lib/generators/lightning_cad/templates/config/webpack/loaders/filter-file.js b/lib/generators/lightning_cad/templates/config/webpack/loaders/filter-file.js
new file mode 100644
index 00000000..e3effe98
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/config/webpack/loaders/filter-file.js
@@ -0,0 +1,26 @@
+/**
+ * Webpack loader to filter server-only or client-only sections of code out of
+ * a file, based on the current target (e.g., 'web' or 'node')
+ */
+export default function (source, target = this.target) {
+ let begin, end
+ if (target === 'web') {
+ begin = '// @begin-server-only'
+ end = '// @end-server-only'
+ } else {
+ begin = '// @begin-client-only'
+ end = '// @end-client-only'
+ }
+
+ let filteredSource = source
+ let beginIndex = filteredSource.indexOf(begin)
+ let endIndex = filteredSource.indexOf(end)
+ while (beginIndex !== -1) {
+ const afterExcludedBlock = endIndex === -1 ? '' : filteredSource.slice(endIndex + end.length)
+ filteredSource = filteredSource.slice(0, beginIndex) + afterExcludedBlock
+ beginIndex = filteredSource.indexOf(begin)
+ endIndex = filteredSource.indexOf(end)
+ }
+
+ return filteredSource
+}
diff --git a/lib/generators/lightning_cad/templates/jasmine.json b/lib/generators/lightning_cad/templates/jasmine.json
new file mode 100644
index 00000000..77dab913
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/jasmine.json
@@ -0,0 +1,6 @@
+{
+ "spec_dir": "spec/javascript",
+ "spec_files": ["shared/**/*[sS]pec.js", "server/**/*[sS]pec.js"],
+ "helpers": ["./shared/TestSetup.js", "./server/TestSetup.js"],
+ "stopSpecOnExpectationFailure": true
+}
diff --git a/lib/generators/lightning_cad/templates/jp-runner.config.js b/lib/generators/lightning_cad/templates/jp-runner.config.js
new file mode 100644
index 00000000..e77b41c1
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/jp-runner.config.js
@@ -0,0 +1,14 @@
+import DetailedTestFormatter from '@rolemodel/jasmine-playwright-runner/src/server/DetailedTestFormatter.js'
+
+export default {
+ specPatterns: [
+ 'spec/javascript/browser/**/*Spec.js',
+ 'spec/javascript/browser/**/*Spec.jsx',
+ 'spec/javascript/shared/**/*Spec.js'
+ ],
+ setupFiles: [
+ 'spec/javascript/shared/TestSetup.js',
+ 'spec/javascript/browser/TestSetup.js'
+ ],
+ formatter: new DetailedTestFormatter()
+}
diff --git a/lib/generators/lightning_cad/templates/lib/tasks/javascript_tests.rake b/lib/generators/lightning_cad/templates/lib/tasks/javascript_tests.rake
new file mode 100644
index 00000000..ffc529fb
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/lib/tasks/javascript_tests.rake
@@ -0,0 +1,6 @@
+desc 'run javascript tests'
+task javascript_tests: :environment do |t|
+ success = true
+ success &&= system('yarn test_shared')
+ abort('JS tests failed') unless success
+end
diff --git a/lib/generators/lightning_cad/templates/spec/javascript/.eslintrc.js b/lib/generators/lightning_cad/templates/spec/javascript/.eslintrc.js
new file mode 100644
index 00000000..560fa983
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/spec/javascript/.eslintrc.js
@@ -0,0 +1,5 @@
+export default {
+ env: {
+ jasmine: true
+ }
+}
diff --git a/lib/generators/lightning_cad/templates/spec/javascript/browser/TestSetup.js b/lib/generators/lightning_cad/templates/spec/javascript/browser/TestSetup.js
new file mode 100644
index 00000000..89bcfaa6
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/spec/javascript/browser/TestSetup.js
@@ -0,0 +1 @@
+// for browser only test setup
diff --git a/lib/generators/lightning_cad/templates/spec/javascript/server/TestSetup.js b/lib/generators/lightning_cad/templates/spec/javascript/server/TestSetup.js
new file mode 100644
index 00000000..d1e95c66
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/spec/javascript/server/TestSetup.js
@@ -0,0 +1 @@
+// for server only test setup
diff --git a/lib/generators/lightning_cad/templates/spec/javascript/shared/TestSetup.js b/lib/generators/lightning_cad/templates/spec/javascript/shared/TestSetup.js
new file mode 100644
index 00000000..9e1a00a6
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/spec/javascript/shared/TestSetup.js
@@ -0,0 +1,9 @@
+import "@rolemodel/lightning-cad/standard-utilities"
+
+import '../../../app/javascript/config/initializers/**/*.js'
+
+import EqualityHelper from '@rolemodel/lightning-cad/standard-utilities/spec/helpers/EqualityHelper'
+import ToIncludeHelper from '@rolemodel/lightning-cad/standard-utilities/spec/helpers/ToIncludeHelper'
+
+beforeAll(EqualityHelper)
+beforeAll(ToIncludeHelper)
diff --git a/lib/generators/lightning_cad/templates/spec/javascript/shared/testSpec.js b/lib/generators/lightning_cad/templates/spec/javascript/shared/testSpec.js
new file mode 100644
index 00000000..4ea8e277
--- /dev/null
+++ b/lib/generators/lightning_cad/templates/spec/javascript/shared/testSpec.js
@@ -0,0 +1,5 @@
+describe('Foo', () => {
+ it('works', () => {
+ expect().nothing()
+ })
+})
diff --git a/lib/generators/lightning_cad/test_generator.rb b/lib/generators/lightning_cad/test_generator.rb
new file mode 100644
index 00000000..10b0e89c
--- /dev/null
+++ b/lib/generators/lightning_cad/test_generator.rb
@@ -0,0 +1,24 @@
+require 'rails'
+
+module LightningCad
+ class TestGenerator < Rails::Generators::Base
+ source_root File.expand_path('./templates', __dir__)
+
+ def add_jasmine_tests
+ say 'Adding jasmine'
+ run "yarn add --dev jasmine@^5.1.0 @rolemodel/jasmine-playwright-runner @testing-library/react"
+
+ run 'npm pkg set scripts.test:server="NODE_OPTIONS=\'--import=./app/javascript/helpers/register_hooks.js\' jasmine --config=jasmine.json"'
+ run 'npm pkg set scripts.test:browser="NODE_ENV=test jp-runner"'
+ run 'npm pkg set scripts.test="yarn test:server && yarn test:browser"'
+
+ copy_file 'jasmine.json'
+ copy_file 'jp-runner.config.js'
+ copy_file 'spec/javascript/.eslintrc.js'
+ copy_file 'spec/javascript/shared/TestSetup.js'
+ copy_file 'spec/javascript/browser/TestSetup.js'
+ copy_file 'spec/javascript/server/TestSetup.js'
+ copy_file 'spec/javascript/shared/testSpec.js'
+ end
+ end
+end
diff --git a/lib/generators/lightning_cad/webpack_generator.rb b/lib/generators/lightning_cad/webpack_generator.rb
new file mode 100644
index 00000000..bf329d90
--- /dev/null
+++ b/lib/generators/lightning_cad/webpack_generator.rb
@@ -0,0 +1,99 @@
+require 'rails'
+
+module LightningCad
+ class WebpackGenerator < Rails::Generators::Base
+ source_root File.expand_path('./templates', __dir__)
+
+ def add_experimental_features
+ say "Adding experimental features to the config"
+
+ experiments = <<-JS
+ experiments: {
+ topLevelAwait: true
+ },
+ JS
+
+ insert_into_file 'webpack.config.js', experiments, before: " output: {"
+ end
+
+ def add_resolve_fallback
+ fallback = ",\n fallback: {\n module: false\n }"
+
+ insert_into_file 'webpack.config.js', fallback, after: "extensions: ['.js', '.jsx']"
+ end
+
+ def add_resolve_aliases
+ say 'Adding aliases to the config'
+ alias_js = <<-JS
+ alias: {
+ // lightning-cad uses 'require' to pull in THREE, but three-bvh-csg uses 'import'.
+ // Because THREE's package.json has an exports field with different files for
+ // import and require, without this alias THREE was being added to the bundle twice.
+ // Not only is that a size problem, but the custom extensions lightning-cad adds to
+ // CJS version of THREE aren't picked up because the ESM version of THREE appears first
+ // in the bundle and THREE has a check to prevent THREE from being instantiated twice.
+ //
+ 'three/examples': path.resolve('node_modules/three/examples'),
+ // TODO: Once lightning-cad migrates to using 'import' to bring in THREE, this alias can be removed.
+ three: path.resolve('node_modules/three/build/three.module.js'),
+
+ // LCAD uses `require` to bring in mathjs, but rails 7 apps uses `import`, so there are
+ // two mathjs module instances in play, and without this line both end up in the final bundle.
+ // TODO: Once LCAD migrates to ES Modules, this line can be removed.
+ mathjs: path.resolve('node_modules/mathjs/lib/esm'),
+
+ // Webpack apparently doesn't support the * in package.imports, so we need to duplicate
+ // package.imports here for webpack.
+ '#components': path.resolve('app/javascript/components'),
+ '#shared': path.resolve('app/javascript/shared')
+ },
+ JS
+
+ insert_into_file 'webpack.config.js', alias_js, before: " fallback: {"
+ end
+
+ def add_loaders
+ say 'Adding loaders to the config'
+
+ copy_file 'config/webpack/loaders/filter-file.js'
+
+ loaders = <<-JS
+,
+ {
+ test: /\.jsx?$/,
+ use: path.resolve('./config/webpack/loaders/filter-file.js')
+ },
+ {
+ test: /\.(mjs|cjs|js|jsx)$/,
+ loader: 'import-glob'
+ },
+ {
+ test: /\.scss/,
+ loader: 'import-glob'
+ },
+ JS
+
+ insert_into_file 'webpack.config.js', loaders, after: "'postcss-loader'\n ]\n }"
+ end
+
+ def add_terser_plugin_options
+ say 'Updating the terser plugin options in the config'
+
+ terserPlugin = <<-JS
+ new TerserPlugin({
+ terserOptions: {
+ keep_classnames: true,
+ mangle: {
+ keep_fnames: /^[A-Z]/,
+ },
+ compress: {
+ keep_fnames: false,
+ }
+ }
+ }),
+ JS
+
+ gsub_file 'webpack.config.js', " new TerserPlugin(),\n", terserPlugin
+ end
+ end
+end
diff --git a/lib/generators/rolemodel/webpack/templates/webpack.config.js b/lib/generators/rolemodel/webpack/templates/webpack.config.js
index 2b91953d..86fc2688 100644
--- a/lib/generators/rolemodel/webpack/templates/webpack.config.js
+++ b/lib/generators/rolemodel/webpack/templates/webpack.config.js
@@ -44,7 +44,7 @@ export default {
loader: 'esbuild-loader',
options: {
loader: 'jsx',
- target: 'es2021'
+ target: 'esnext'
},
// ES Module has stricter rules than CommonJS, so file extensions must be
// used in import statements. To ease migration from CommonJS to ESM,