From 5b592b9a45cdfb772d1614d249896abdc50ec5ec Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Tue, 7 May 2024 21:41:02 +0300 Subject: [PATCH] Initial commit --- .github/workflows/main.yml | 27 ++++ .gitignore | 8 + .standard.yml | 3 + CHANGELOG.md | 3 + Gemfile | 10 ++ Gemfile.lock | 146 ++++++++++++++++++ LICENSE.txt | 21 +++ README.md | 78 ++++++++++ Rakefile | 6 + app/assets/javascripts/turbo-mount.js | 119 ++++++++++++++ app/assets/javascripts/turbo-mount.min.js | 2 + app/assets/javascripts/turbo-mount.min.js.map | 1 + bin/console | 11 ++ bin/setup | 8 + lib/turbo/mount.rb | 9 ++ lib/turbo/mount/engine.rb | 30 ++++ lib/turbo/mount/helpers.rb | 30 ++++ lib/turbo/mount/version.rb | 7 + packages/turbo-mount/.gitignore | 3 + packages/turbo-mount/package.json | 66 ++++++++ packages/turbo-mount/rollup.config.js | 52 +++++++ packages/turbo-mount/src/controllers/index.ts | 4 + .../src/controllers/turbo-mount-controller.ts | 54 +++++++ .../turbo-mount-react-controller.ts | 15 ++ .../turbo-mount-svelte-controller.ts | 13 ++ .../controllers/turbo-mount-vue-controller.ts | 14 ++ packages/turbo-mount/src/index.ts | 2 + packages/turbo-mount/src/turbo-mount.ts | 68 ++++++++ packages/turbo-mount/tsconfig.json | 25 +++ turbo-mount.gemspec | 30 ++++ 30 files changed, 865 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .standard.yml create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/javascripts/turbo-mount.js create mode 100644 app/assets/javascripts/turbo-mount.min.js create mode 100644 app/assets/javascripts/turbo-mount.min.js.map create mode 100755 bin/console create mode 100755 bin/setup create mode 100644 lib/turbo/mount.rb create mode 100644 lib/turbo/mount/engine.rb create mode 100644 lib/turbo/mount/helpers.rb create mode 100644 lib/turbo/mount/version.rb create mode 100644 packages/turbo-mount/.gitignore create mode 100644 packages/turbo-mount/package.json create mode 100644 packages/turbo-mount/rollup.config.js create mode 100644 packages/turbo-mount/src/controllers/index.ts create mode 100644 packages/turbo-mount/src/controllers/turbo-mount-controller.ts create mode 100644 packages/turbo-mount/src/controllers/turbo-mount-react-controller.ts create mode 100644 packages/turbo-mount/src/controllers/turbo-mount-svelte-controller.ts create mode 100644 packages/turbo-mount/src/controllers/turbo-mount-vue-controller.ts create mode 100644 packages/turbo-mount/src/index.ts create mode 100644 packages/turbo-mount/src/turbo-mount.ts create mode 100644 packages/turbo-mount/tsconfig.json create mode 100644 turbo-mount.gemspec diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..6d57e77 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: Ruby + +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - '3.2.3' + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run the default task + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..6d67c99 --- /dev/null +++ b/.standard.yml @@ -0,0 +1,3 @@ +# For available configuration options, see: +# https://github.com/standardrb/standard +ruby_version: 3.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a25c7c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [Unreleased] + +- Initial release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..5d03aaa --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in turbo-mount.gemspec +gemspec + +gem "rake", "~> 13.0" + +gem "standard", "~> 1.3" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..10023f6 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,146 @@ +PATH + remote: . + specs: + turbo-mount (0.1.0) + railties (>= 6.0.0) + +GEM + remote: https://rubygems.org/ + specs: + actionpack (7.1.3.2) + actionview (= 7.1.3.2) + activesupport (= 7.1.3.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actionview (7.1.3.2) + activesupport (= 7.1.3.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.7) + builder (3.2.4) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + crass (1.0.6) + drb (2.2.1) + erubi (1.12.0) + i18n (1.14.4) + concurrent-ruby (~> 1.0) + io-console (0.7.2) + irb (1.13.1) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.7.2) + language_server-protocol (3.17.0.3) + lint_roller (1.1.0) + loofah (2.22.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + minitest (5.22.3) + mutex_m (0.2.0) + nokogiri (1.16.4-arm64-darwin) + racc (~> 1.4) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + psych (5.1.2) + stringio + racc (1.7.3) + rack (3.0.10) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.2.1) + rdoc (6.6.3.1) + psych (>= 4.0.0) + regexp_parser (2.9.0) + reline (0.5.5) + io-console (~> 0.5) + rexml (3.2.6) + rubocop (1.62.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.31.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (1.13.0) + standard (1.35.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.62.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.3.1) + lint_roller (~> 1.1) + rubocop-performance (~> 1.20.2) + stringio (3.1.0) + thor (1.3.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + webrick (1.8.1) + zeitwerk (2.6.13) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + rake (~> 13.0) + standard (~> 1.3) + turbo-mount! + +BUNDLED WITH + 2.5.7 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8407968 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Svyatoslav Kryukov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8a9d5d --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Turbo::Mount + +[![Gem Version](https://badge.fury.io/rb/turbo-mount.svg)](https://rubygems.org/gems/turbo-mount) + +`Turbo::Mount` is a simple gem that allows you to add highly interactive components from React, Vue, Svelte, and other frameworks to your Hotwire application. + +## Installation + +Install the gem and add to the application's Gemfile by executing: + + $ bundle add turbo-mount + +If bundler is not being used to manage dependencies, install the gem by executing: + + $ gem install turbo-mount + +## Usage + +First, you need to initialize `TurboMount` and register the components you want to use: + +```js +import { Application } from "@hotwired/stimulus" +import { TurboMount } from "turbo-mount" + +const application = Application.start() + +// Initialize TurboMount and register the react stimulus controller +const turboMount = new TurboMount({application, framework: "react"}); + +// Register the components you want to use +import { SketchPicker } from 'react-color' +turboMount.register('SketchPicker', SketchPicker); +``` + +Now you can use view helpers to mount the components: + +```erb + +<%= turbo_mount_component("SketchPicker", framework: "react", props: {color: "#034"}) %> + +<%# or using alias %> +<%= turbo_mount_react_component("SketchPicker", props: {color: "#430"}) %> +``` + +In case you need to customize the component's behavior, or pass functions as props, you can create a custom controller: + +```js +// javascript/controllers/turbo_mount_react_sketch_picker_controller.js + +import { TurboMountReactController } from "turbo-mount" + +export default class extends TurboMountReactController { + get componentProps() { + return { + ...this.propsValue, + onChange: this.onChange, + }; + } + + onChange = (color) => { + this.propsValue = { ...this.propsValue, color: color.hex }; + }; +} +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/turbo-mount. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..12e5882 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "standard/rake" + +task default: :standard diff --git a/app/assets/javascripts/turbo-mount.js b/app/assets/javascripts/turbo-mount.js new file mode 100644 index 0000000..1925677 --- /dev/null +++ b/app/assets/javascripts/turbo-mount.js @@ -0,0 +1,119 @@ +import { Controller } from '@hotwired/stimulus'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { createApp } from 'vue'; + +class TurboMountController extends Controller { + connect() { + this._umountComponentCallback || (this._umountComponentCallback = this.mountComponent(this.mountElement, this.resolvedComponent, this.componentProps)); + } + disconnect() { + this.umountComponent(); + } + propsValueChanged() { + this.umountComponent(); + this._umountComponentCallback = this.mountComponent(this.mountElement, this.resolvedComponent, this.componentProps); + } + get componentProps() { + return this.propsValue; + } + get mountElement() { + return this.element; + } + get resolvedComponent() { + return this.resolveComponent(this.componentValue); + } + umountComponent() { + this._umountComponentCallback && this._umountComponentCallback(); + this._umountComponentCallback = undefined; + } + resolveComponent(component) { + const app = this.application; + return app.turboMount[this.framework].resolve(component); + } +} +TurboMountController.values = { + props: Object, + component: String +}; + +class TurboMountReactController extends TurboMountController { + constructor() { + super(...arguments); + this.framework = "react"; + } + mountComponent(el, Component, props) { + const root = createRoot(el); + root.render(createElement(Component, props)); + return root.unmount; + } +} + +class TurboMountSvelteController extends TurboMountController { + constructor() { + super(...arguments); + this.framework = "svelte"; + } + mountComponent(el, Component, props) { + const component = new Component({ target: el, props }); + return component.$destroy; + } +} + +class TurboMountVueController extends TurboMountController { + constructor() { + super(...arguments); + this.framework = "vue"; + } + mountComponent(el, Component, props) { + const app = createApp(Component, props); + app.mount(el); + return app.unmount; + } +} + +class TurboMount { + constructor(props) { + var _a; + this.components = new Map(); + this.application = props.application; + this.framework = props.framework; + this.baseController = undefined; + if (!this.framework) { + throw new Error('framework is required'); + } + (_a = this.application).turboMount || (_a.turboMount = {}); + this.application.turboMount[this.framework] = this; + this.baseController = TurboMount.frameworkControllers.get(this.framework); + if (this.baseController) { + this.application.register(`turbo-mount-${this.framework}`, this.baseController); + } + } + register(name, component, controller) { + controller || (controller = this.baseController); + if (this.components.has(name)) { + throw new Error(`Component '${name}' is already registered.`); + } + this.components.set(name, component); + if (controller) { + const controllerName = `turbo-mount-${this.framework}-${this.camelToKebabCase(name)}`; + this.application.register(controllerName, controller); + } + } + resolve(name) { + const component = this.components.get(name); + if (!component) { + throw new Error(`Unknown component: ${name}`); + } + return component; + } + camelToKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + } +} +TurboMount.frameworkControllers = new Map(); +TurboMount.frameworkControllers.set("react", TurboMountReactController); +TurboMount.frameworkControllers.set("svelte", TurboMountSvelteController); +TurboMount.frameworkControllers.set("vue", TurboMountVueController); + +export { TurboMount, TurboMountController, TurboMountReactController, TurboMountSvelteController, TurboMountVueController }; diff --git a/app/assets/javascripts/turbo-mount.min.js b/app/assets/javascripts/turbo-mount.min.js new file mode 100644 index 0000000..f680eba --- /dev/null +++ b/app/assets/javascripts/turbo-mount.min.js @@ -0,0 +1,2 @@ +import{Controller as t}from"@hotwired/stimulus";import{createElement as o}from"react";import{createRoot as e}from"react-dom/client";import{createApp as r}from"vue";class n extends t{connect(){this._umountComponentCallback||(this._umountComponentCallback=this.mountComponent(this.mountElement,this.resolvedComponent,this.componentProps))}disconnect(){this.umountComponent()}propsValueChanged(){this.umountComponent(),this._umountComponentCallback=this.mountComponent(this.mountElement,this.resolvedComponent,this.componentProps)}get componentProps(){return this.propsValue}get mountElement(){return this.element}get resolvedComponent(){return this.resolveComponent(this.componentValue)}umountComponent(){this._umountComponentCallback&&this._umountComponentCallback(),this._umountComponentCallback=void 0}resolveComponent(t){return this.application.turboMount[this.framework].resolve(t)}}n.values={props:Object,component:String};class s extends n{constructor(){super(...arguments),this.framework="react"}mountComponent(t,r,n){const s=e(t);return s.render(o(r,n)),s.unmount}}class m extends n{constructor(){super(...arguments),this.framework="svelte"}mountComponent(t,o,e){return new o({target:t,props:e}).$destroy}}class i extends n{constructor(){super(...arguments),this.framework="vue"}mountComponent(t,o,e){const n=r(o,e);return n.mount(t),n.unmount}}class a{constructor(t){var o;if(this.components=new Map,this.application=t.application,this.framework=t.framework,this.baseController=void 0,!this.framework)throw new Error("framework is required");(o=this.application).turboMount||(o.turboMount={}),this.application.turboMount[this.framework]=this,this.baseController=a.frameworkControllers.get(this.framework),this.baseController&&this.application.register(`turbo-mount-${this.framework}`,this.baseController)}register(t,o,e){if(e||(e=this.baseController),this.components.has(t))throw new Error(`Component '${t}' is already registered.`);if(this.components.set(t,o),e){const o=`turbo-mount-${this.framework}-${this.camelToKebabCase(t)}`;this.application.register(o,e)}}resolve(t){const o=this.components.get(t);if(!o)throw new Error(`Unknown component: ${t}`);return o}camelToKebabCase(t){return t.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}}a.frameworkControllers=new Map,a.frameworkControllers.set("react",s),a.frameworkControllers.set("svelte",m),a.frameworkControllers.set("vue",i);export{a as TurboMount,n as TurboMountController,s as TurboMountReactController,m as TurboMountSvelteController,i as TurboMountVueController}; +//# sourceMappingURL=turbo-mount.min.js.map diff --git a/app/assets/javascripts/turbo-mount.min.js.map b/app/assets/javascripts/turbo-mount.min.js.map new file mode 100644 index 0000000..b42dc7b --- /dev/null +++ b/app/assets/javascripts/turbo-mount.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"turbo-mount.min.js","sources":["../src/controllers/turbo-mount-controller.ts","../src/controllers/turbo-mount-react-controller.ts","../src/turbo-mount.ts"],"sourcesContent":["import {Controller, ControllerConstructor} from \"@hotwired/stimulus\"\nimport {ApplicationWithTurboMount} from \"../turbo-mount\";\n\nexport abstract class TurboMountController extends Controller {\n static values = {\n props: Object,\n component: String\n }\n declare readonly propsValue: object;\n declare readonly componentValue: string;\n\n abstract framework: string;\n\n abstract mountComponent(el: Element, component: string, props: object): () => void;\n\n umountComponentCallback?: () => void;\n\n connect() {\n this.umountComponentCallback ||= this.mountComponent(this.element, this.componentValue, this.propsValue);\n }\n\n disconnect() {\n this.umountComponent();\n }\n\n propsValueChanged() {\n this.umountComponent();\n this.umountComponentCallback = this.mountComponent(this.element, this.componentValue, this.componentProps);\n }\n\n get componentProps() {\n return this.propsValue;\n }\n\n umountComponent() {\n this.umountComponentCallback && this.umountComponentCallback();\n this.umountComponentCallback = undefined;\n }\n\n resolveComponent(component: string): T {\n const app = this.application as ApplicationWithTurboMount\n return app.turboMount[this.framework].resolve(component);\n }\n\n static shouldRegister(controllerConstructor: ControllerConstructor, identifier: string, application: ApplicationWithTurboMount) {\n const framework = identifier.split('-')[2];\n console.log({\n framework,\n identifier,\n modules: application.router.modules.map(m => m.identifier)\n })\n if (framework && identifier.startsWith(`turbo-mount-${framework}-`)\n && application.router.modules.find(m => m.identifier === identifier)) {\n console.log(controllerConstructor, application.turboMount[framework].baseController)\n return controllerConstructor !== application.turboMount[framework].baseController;\n }\n\n return true;\n }\n}\n\n\n","import {createElement, ComponentType} from \"react\";\nimport {createRoot} from \"react-dom/client\";\n\nimport {TurboMountController} from \"./turbo-mount-controller\";\n\nexport class TurboMountReactController extends TurboMountController {\n framework = \"react\"\n\n mountComponent(el: Element, component: string, props: object) {\n const Component = this.resolveComponent(component);\n const root = createRoot(el);\n\n root.render(createElement(Component, props))\n\n return () => {\n root.unmount()\n }\n }\n}\n","import {Application, ControllerConstructor} from '@hotwired/stimulus';\nimport {TurboMountReactController} from \"./controllers\";\n\nexport interface ApplicationWithTurboMount extends Application {\n turboMount: { [framework: string]: TurboMount };\n}\n\nexport class TurboMount {\n static frameworkControllers: Map = new Map();\n\n components: Map;\n application: ApplicationWithTurboMount;\n framework: string;\n baseController?: ControllerConstructor;\n\n constructor(props: { application: Application, framework: string }) {\n this.components = new Map();\n this.application = props.application as ApplicationWithTurboMount;\n this.framework = props.framework;\n this.baseController = undefined;\n\n if (!this.framework) {\n throw new Error('framework is required');\n }\n\n this.application.turboMount ||= {};\n this.application.turboMount[this.framework] = this;\n\n this.baseController = TurboMount.frameworkControllers.get(this.framework);\n\n if (this.baseController) {\n this.application.register(`turbo-mount-${this.framework}`, this.baseController);\n }\n }\n\n camelToKebabCase(str: string) {\n return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();\n }\n\n register(name: string, component: T) {\n if (this.components.has(name)) {\n throw new Error(`Component '${name}' is already registered.`);\n }\n this.components.set(name, component);\n\n if (this.baseController) {\n const controllerName = `turbo-mount-${this.framework}-${this.camelToKebabCase(name)}`;\n if (!this.application.router.modules.find(m => m.identifier === controllerName)) {\n this.application.register(controllerName, this.baseController);\n }\n }\n }\n\n resolve(name: string) {\n const component = this.components.get(name);\n if (!component) {\n throw new Error(`Unknown component: ${name}`);\n }\n return component;\n }\n}\n\nTurboMount.frameworkControllers.set(\"react\", TurboMountReactController)\n"],"names":["TurboMountController","Controller","connect","this","umountComponentCallback","mountComponent","element","componentValue","propsValue","disconnect","umountComponent","propsValueChanged","componentProps","undefined","resolveComponent","component","application","turboMount","framework","resolve","shouldRegister","controllerConstructor","identifier","split","console","log","modules","router","map","m","startsWith","find","baseController","values","props","Object","String","TurboMountReactController","constructor","el","Component","root","createRoot","render","createElement","unmount","TurboMount","components","Map","Error","_a","frameworkControllers","get","register","camelToKebabCase","str","replace","toLowerCase","name","has","set","controllerName"],"mappings":"oIAGM,MAAgBA,UAAgCC,EAclD,OAAAC,GACIC,KAAKC,0BAALD,KAAKC,wBAA4BD,KAAKE,eAAeF,KAAKG,QAASH,KAAKI,eAAgBJ,KAAKK,YAChG,CAED,UAAAC,GACIN,KAAKO,iBACR,CAED,iBAAAC,GACIR,KAAKO,kBACLP,KAAKC,wBAA0BD,KAAKE,eAAeF,KAAKG,QAASH,KAAKI,eAAgBJ,KAAKS,eAC9F,CAED,kBAAIA,GACA,OAAOT,KAAKK,UACf,CAED,eAAAE,GACIP,KAAKC,yBAA2BD,KAAKC,0BACrCD,KAAKC,6BAA0BS,CAClC,CAED,gBAAAC,CAAiBC,GAEb,OADYZ,KAAKa,YACNC,WAAWd,KAAKe,WAAWC,QAAQJ,EACjD,CAED,qBAAOK,CAAeC,EAA8CC,EAAoBN,GACpF,MAAME,EAAYI,EAAWC,MAAM,KAAK,GAMxC,OALAC,QAAQC,IAAI,CACRP,YACAI,aACAI,QAASV,EAAYW,OAAOD,QAAQE,KAAIC,GAAKA,EAAEP,iBAE/CJ,GAAaI,EAAWQ,WAAW,eAAeZ,OAC/CF,EAAYW,OAAOD,QAAQK,MAAKF,GAAKA,EAAEP,aAAeA,OACzDE,QAAQC,IAAIJ,EAAuBL,EAAYC,WAAWC,GAAWc,gBAC9DX,IAA0BL,EAAYC,WAAWC,GAAWc,eAI1E,EAtDMhC,EAAAiC,OAAS,CACZC,MAAOC,OACPpB,UAAWqB,QCDb,MAAOC,UAAkCrC,EAA/C,WAAAsC,uBACInC,KAASe,UAAG,OAYf,CAVG,cAAAb,CAAekC,EAAaxB,EAAmBmB,GAC3C,MAAMM,EAAYrC,KAAKW,iBAAiBC,GAClC0B,EAAOC,EAAWH,GAIxB,OAFAE,EAAKE,OAAOC,EAAcJ,EAAWN,IAE9B,KACHO,EAAKI,SAAS,CAErB,QCVQC,EAQT,WAAAR,CAAYJ,SAMR,GALA/B,KAAK4C,WAAa,IAAIC,IACtB7C,KAAKa,YAAckB,EAAMlB,YACzBb,KAAKe,UAAYgB,EAAMhB,UACvBf,KAAK6B,oBAAiBnB,GAEjBV,KAAKe,UACN,MAAM,IAAI+B,MAAM,0BAGpBC,EAAA/C,KAAKa,aAAYC,aAAAiC,EAAAjC,WAAe,CAAA,GAChCd,KAAKa,YAAYC,WAAWd,KAAKe,WAAaf,KAE9CA,KAAK6B,eAAiBc,EAAWK,qBAAqBC,IAAIjD,KAAKe,WAE3Df,KAAK6B,gBACL7B,KAAKa,YAAYqC,SAAS,eAAelD,KAAKe,YAAaf,KAAK6B,eAEvE,CAED,gBAAAsB,CAAiBC,GACb,OAAOA,EAAIC,QAAQ,kBAAmB,SAASC,aAClD,CAED,QAAAJ,CAASK,EAAc3C,GACnB,GAAIZ,KAAK4C,WAAWY,IAAID,GACpB,MAAM,IAAIT,MAAM,cAAcS,6BAIlC,GAFAvD,KAAK4C,WAAWa,IAAIF,EAAM3C,GAEtBZ,KAAK6B,eAAgB,CACrB,MAAM6B,EAAiB,eAAe1D,KAAKe,aAAaf,KAAKmD,iBAAiBI,KACzEvD,KAAKa,YAAYW,OAAOD,QAAQK,MAAKF,GAAKA,EAAEP,aAAeuC,KAC5D1D,KAAKa,YAAYqC,SAASQ,EAAgB1D,KAAK6B,eAEtD,CACJ,CAED,OAAAb,CAAQuC,GACJ,MAAM3C,EAAYZ,KAAK4C,WAAWK,IAAIM,GACtC,IAAK3C,EACD,MAAM,IAAIkC,MAAM,sBAAsBS,KAE1C,OAAO3C,CACV,EAnDM+B,EAAAK,qBAA2D,IAAIH,IAsD1EF,EAAWK,qBAAqBS,IAAI,QAASvB"} \ No newline at end of file diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..dc2a315 --- /dev/null +++ b/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "turbo/mount" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/turbo/mount.rb b/lib/turbo/mount.rb new file mode 100644 index 0000000..682ee22 --- /dev/null +++ b/lib/turbo/mount.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require_relative "mount/version" +require_relative "mount/engine" + +module Turbo + module Mount + end +end diff --git a/lib/turbo/mount/engine.rb b/lib/turbo/mount/engine.rb new file mode 100644 index 0000000..23ff35c --- /dev/null +++ b/lib/turbo/mount/engine.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails/engine" +require "turbo/mount/helpers" + +module Turbo + module Mount + class Engine < Rails::Engine + # If you don't want to precompile assets (e.g., you're using jsbundling), + # you can do this in an initializer: + # + # config.after_initialize do + # config.assets.precompile -= Turbo::Mount::Engine::PRECOMPILE_ASSETS + # end + PRECOMPILE_ASSETS = %w[turbo-mount.js turbo-mount.min.js turbo-mount.min.js.map].freeze + + initializer "turbo-mount.assets" do + if Rails.application.config.respond_to?(:assets) + Rails.application.config.assets.precompile += PRECOMPILE_ASSETS + end + end + + initializer "turbo-mount.helpers" do + ActiveSupport.on_load(:action_controller_base) do + helper Turbo::Mount::Helpers + end + end + end + end +end diff --git a/lib/turbo/mount/helpers.rb b/lib/turbo/mount/helpers.rb new file mode 100644 index 0000000..06c078d --- /dev/null +++ b/lib/turbo/mount/helpers.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Turbo + module Mount + module Helpers + def turbo_mount_component(component_name, framework:, props: {}, tag: "div", **attrs) + raise TypeError, "Component name expected" unless component_name.is_a? String + + attrs["data-controller"] = "turbo-mount-#{framework}-#{component_name.underscore.dasherize}" + prefix = "data-#{attrs["data-controller"]}" + attrs["#{prefix}-component-value"] = component_name + attrs["#{prefix}-props-value"] = json_escape(props.to_json) if props.present? + + content_tag(tag, nil, attrs) + end + + def turbo_mount_react_component(component_name, **attrs) + turbo_mount_component(component_name, framework: "react", **attrs) + end + + def turbo_mount_svelte_component(component_name, **attrs) + turbo_mount_component(component_name, framework: "svelte", **attrs) + end + + def turbo_mount_vue_component(component_name, **attrs) + turbo_mount_component(component_name, framework: "vue", **attrs) + end + end + end +end diff --git a/lib/turbo/mount/version.rb b/lib/turbo/mount/version.rb new file mode 100644 index 0000000..37c3d93 --- /dev/null +++ b/lib/turbo/mount/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Turbo + module Mount + VERSION = "0.1.0" + end +end diff --git a/packages/turbo-mount/.gitignore b/packages/turbo-mount/.gitignore new file mode 100644 index 0000000..8a77ec0 --- /dev/null +++ b/packages/turbo-mount/.gitignore @@ -0,0 +1,3 @@ +/dist +/node_modules +package-lock.json diff --git a/packages/turbo-mount/package.json b/packages/turbo-mount/package.json new file mode 100644 index 0000000..83d8785 --- /dev/null +++ b/packages/turbo-mount/package.json @@ -0,0 +1,66 @@ +{ + "name": "turbo-mount", + "version": "0.1.0", + "license": "MIT", + "description": "Use React, Vue, and other components with Hotwire", + "author": "Svyatoslav Kryukov ", + "repository": { + "type": "git", + "url": "https://github.com/skryukov/turbo-mount.git", + "directory": "packages/turbo-mount" + }, + "bugs": { + "url": "https://github.com/skryukov/turbo-mount/issues" + }, + "scripts": { + "clean": "rm -rf dist", + "types": "tsc --noEmit false --declaration true --emitDeclarationOnly true --outDir dist/types", + "build": "npm run types && rollup -c", + "prerelease": "npm run clean && npm run build && git --no-pager diff && echo && npm pack --dry-run" + }, + "module": "dist/turbo-mount.js", + "main": "dist/turbo-mount.umd.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist/turbo-mount.js", + "dist/turbo-mount.umd.js", + "dist/types/**/*" + ], + "devDependencies": { + "@babel/types": "^7.24.5", + "@rollup/plugin-typescript": "^11.1.6", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "react": ">= 17.0", + "react-dom": ">= 17.0", + "rollup": "^2.79.1", + "rollup-plugin-terser": "^7.0.2", + "svelte": ">= 3.0", + "tslib": "^2.6.2", + "typescript": "^5.4.5", + "vue": ">= 3.0" + }, + "dependencies": { + "@hotwired/stimulus": ">= 3.0" + }, + "peerDependencies": { + "react": ">= 17.0", + "react-dom": ">= 17.0", + "svelte": ">= 3.0", + "vue": ">= 3.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } +} diff --git a/packages/turbo-mount/rollup.config.js b/packages/turbo-mount/rollup.config.js new file mode 100644 index 0000000..c37e7a0 --- /dev/null +++ b/packages/turbo-mount/rollup.config.js @@ -0,0 +1,52 @@ +import typescript from "@rollup/plugin-typescript"; +import {terser} from "rollup-plugin-terser"; + +const external = [ + "@hotwired/stimulus", + "react", + "react-dom/client", + "vue", +] + +export default [{ + input: "src/index.ts", + output: [ + { + name: "TurboMount", + file: "dist/turbo-mount.umd.js", + format: "umd", + globals: { + "react": "React", + "react-dom/client": "ReactDOMClient", + "@hotwired/stimulus": "Stimulus", + "vue": "Vue" + }, + }, + { + file: "dist/turbo-mount.js", + format: "es" + } + + ], + plugins: [ + typescript() + ], + external +}, + { + input: "src/index.ts", + output: { + file: "dist/turbo-mount.min.js", + format: "es", + sourcemap: true + }, + plugins: [ + typescript(), + terser({ + mangle: true, + compress: true + }) + ], + external + } +]; diff --git a/packages/turbo-mount/src/controllers/index.ts b/packages/turbo-mount/src/controllers/index.ts new file mode 100644 index 0000000..4221e4f --- /dev/null +++ b/packages/turbo-mount/src/controllers/index.ts @@ -0,0 +1,4 @@ +export {TurboMountController} from "./turbo-mount-controller" +export {TurboMountReactController} from "./turbo-mount-react-controller" +export {TurboMountSvelteController} from "./turbo-mount-svelte-controller" +export {TurboMountVueController} from "./turbo-mount-vue-controller" diff --git a/packages/turbo-mount/src/controllers/turbo-mount-controller.ts b/packages/turbo-mount/src/controllers/turbo-mount-controller.ts new file mode 100644 index 0000000..6ad59c6 --- /dev/null +++ b/packages/turbo-mount/src/controllers/turbo-mount-controller.ts @@ -0,0 +1,54 @@ +import {Controller} from "@hotwired/stimulus" +import {ApplicationWithTurboMount} from "../turbo-mount"; + +export abstract class TurboMountController extends Controller { + static values = { + props: Object, + component: String + } + declare readonly propsValue: object; + declare readonly componentValue: string; + + abstract framework: string; + + abstract mountComponent(el: Element, Component: T, props: object): () => void; + + _umountComponentCallback?: () => void; + + connect() { + this._umountComponentCallback ||= this.mountComponent(this.mountElement, this.resolvedComponent, this.componentProps); + } + + disconnect() { + this.umountComponent(); + } + + propsValueChanged() { + this.umountComponent(); + this._umountComponentCallback = this.mountComponent(this.mountElement, this.resolvedComponent, this.componentProps); + } + + get componentProps() { + return this.propsValue; + } + + get mountElement() { + return this.element; + } + + get resolvedComponent() { + return this.resolveComponent(this.componentValue); + } + + umountComponent() { + this._umountComponentCallback && this._umountComponentCallback(); + this._umountComponentCallback = undefined; + } + + resolveComponent(component: string): T { + const app = this.application as ApplicationWithTurboMount + return app.turboMount[this.framework].resolve(component); + } +} + + diff --git a/packages/turbo-mount/src/controllers/turbo-mount-react-controller.ts b/packages/turbo-mount/src/controllers/turbo-mount-react-controller.ts new file mode 100644 index 0000000..71fa2f5 --- /dev/null +++ b/packages/turbo-mount/src/controllers/turbo-mount-react-controller.ts @@ -0,0 +1,15 @@ +import {createElement, ComponentType} from "react"; +import {createRoot} from "react-dom/client"; + +import {TurboMountController} from "./turbo-mount-controller"; + +export class TurboMountReactController extends TurboMountController { + framework = "react" + + mountComponent(el: Element, Component: ComponentType, props: object) { + const root = createRoot(el); + root.render(createElement(Component, props)) + + return root.unmount + } +} diff --git a/packages/turbo-mount/src/controllers/turbo-mount-svelte-controller.ts b/packages/turbo-mount/src/controllers/turbo-mount-svelte-controller.ts new file mode 100644 index 0000000..ba7287f --- /dev/null +++ b/packages/turbo-mount/src/controllers/turbo-mount-svelte-controller.ts @@ -0,0 +1,13 @@ +import {TurboMountController} from "./turbo-mount-controller"; + +import {ComponentType} from "svelte"; + +export class TurboMountSvelteController extends TurboMountController { + framework = "svelte" + + mountComponent(el: Element, Component: ComponentType, props: object) { + const component = new Component({ target: el, props }) + + return component.$destroy + } +} diff --git a/packages/turbo-mount/src/controllers/turbo-mount-vue-controller.ts b/packages/turbo-mount/src/controllers/turbo-mount-vue-controller.ts new file mode 100644 index 0000000..6605167 --- /dev/null +++ b/packages/turbo-mount/src/controllers/turbo-mount-vue-controller.ts @@ -0,0 +1,14 @@ +import {TurboMountController} from "./turbo-mount-controller"; + +import { createApp, App } from "vue"; + +export class TurboMountVueController extends TurboMountController { + framework = "vue" + + mountComponent(el: Element, Component: App, props: object) { + const app = createApp(Component, props as Record); + app.mount(el) + + return app.unmount + } +} diff --git a/packages/turbo-mount/src/index.ts b/packages/turbo-mount/src/index.ts new file mode 100644 index 0000000..00f458e --- /dev/null +++ b/packages/turbo-mount/src/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers" +export * from "./turbo-mount" diff --git a/packages/turbo-mount/src/turbo-mount.ts b/packages/turbo-mount/src/turbo-mount.ts new file mode 100644 index 0000000..41d51e5 --- /dev/null +++ b/packages/turbo-mount/src/turbo-mount.ts @@ -0,0 +1,68 @@ +import {Application, ControllerConstructor} from '@hotwired/stimulus'; +import { + TurboMountReactController, + TurboMountSvelteController, + TurboMountVueController +} from "./controllers"; + +export interface ApplicationWithTurboMount extends Application { + turboMount: { [framework: string]: TurboMount }; +} + +export class TurboMount { + static frameworkControllers: Map = new Map(); + + components: Map; + application: ApplicationWithTurboMount; + framework: string; + baseController?: ControllerConstructor; + + constructor(props: { application: Application, framework: string }) { + this.components = new Map(); + this.application = props.application as ApplicationWithTurboMount; + this.framework = props.framework; + this.baseController = undefined; + + if (!this.framework) { + throw new Error('framework is required'); + } + + this.application.turboMount ||= {}; + this.application.turboMount[this.framework] = this; + + this.baseController = TurboMount.frameworkControllers.get(this.framework); + + if (this.baseController) { + this.application.register(`turbo-mount-${this.framework}`, this.baseController); + } + } + + register(name: string, component: T, controller?: ControllerConstructor) { + controller ||= this.baseController; + if (this.components.has(name)) { + throw new Error(`Component '${name}' is already registered.`); + } + this.components.set(name, component); + + if (controller) { + const controllerName = `turbo-mount-${this.framework}-${this.camelToKebabCase(name)}`; + this.application.register(controllerName, controller); + } + } + + resolve(name: string) { + const component = this.components.get(name); + if (!component) { + throw new Error(`Unknown component: ${name}`); + } + return component; + } + + camelToKebabCase(str: string) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + } +} + +TurboMount.frameworkControllers.set("react", TurboMountReactController) +TurboMount.frameworkControllers.set("svelte", TurboMountSvelteController) +TurboMount.frameworkControllers.set("vue", TurboMountVueController) diff --git a/packages/turbo-mount/tsconfig.json b/packages/turbo-mount/tsconfig.json new file mode 100644 index 0000000..8ecf92f --- /dev/null +++ b/packages/turbo-mount/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": [ + "es2015", + "dom" + ], + "target": "es2017", + "module": "es2015", + "moduleResolution": "node", + "noUnusedLocals": true, + "rootDir": "src", + "strict": true, + "removeComments": true, + "outDir": "dist", + "baseUrl": ".", + "noEmit": false, + "declaration": false + }, + "exclude": [ + "dist" + ], + "include": [ + "src/**/*" + ] +} diff --git a/turbo-mount.gemspec b/turbo-mount.gemspec new file mode 100644 index 0000000..f2cf686 --- /dev/null +++ b/turbo-mount.gemspec @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "lib/turbo/mount/version" + +Gem::Specification.new do |spec| + spec.name = "turbo-mount" + spec.version = Turbo::Mount::VERSION + spec.authors = ["Svyatoslav Kryukov"] + spec.email = ["me@skryukov.dev"] + + spec.summary = "Use React, Vue, Svelte, and other components with Hotwire." + spec.description = "Add highly interactive components to your Hotwire application with Turbo Mount." + spec.homepage = "https://github.com/skryukov/turbo-mount" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.0.0" + + spec.metadata = { + "bug_tracker_uri" => "#{spec.homepage}/issues", + "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md", + "documentation_uri" => "#{spec.homepage}/blob/main/README.md", + "homepage_uri" => spec.homepage, + "source_code_uri" => spec.homepage, + "rubygems_mfa_required" => "true" + } + + spec.files = Dir["{app,lib}/**/*", "CHANGELOG.md", "LICENSE.txt", "README.md"] + spec.require_paths = ["lib"] + + spec.add_dependency "railties", ">= 6.0.0" +end