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