Rails Engine with Importmap and TailwindCSS
Importmap เป็น default frontend ของ Rails 7+ ที่ส่ง JavaScript เป็น ES module ตรงๆ ไปยัง browser ไม่ต้องมี Node, bundler, หรือ transpile step ทำให้ engine ที่ใช้ importmap ติดตั้งและ deploy ง่ายกว่า แบบ Vite — แลกกับการไม่มี HMR และไม่เหมาะกับ JS heavy framework แบบ Vue/React
โพสต์นี้พาทำ engine ชื่อ blorgh ที่มี importmap ของตัวเอง โหลด Turbo + Stimulus และ build Tailwind CSS แยกจาก host app
Prerequisites
- Ruby 3.2+
- Rails 7+
- ความคุ้นเคยกับ Rails engine, importmap-rails และ Stimulus เบื้องต้น
สร้าง engine
1
rails plugin new blorgh --mountable
--mountable ให้ engine มี namespace แยก (Blorgh::) และมี Rails app structure ของตัวเอง (controllers, views, routes, layouts)
ติดตั้ง importmap + Hotwire
ประกาศใน gemspec ของ engine:
1
2
3
spec.add_dependency 'importmap-rails'
spec.add_dependency 'turbo-rails'
spec.add_dependency 'stimulus-rails'
ทั้งสามต้องเป็น runtime dependency เพราะ engine ใช้ helper, asset path, และ importmap-rails ตอนรันจริง ไม่ได้ใช้แค่ตอนพัฒนา
Engine initializer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require "importmap-rails"
require "turbo-rails"
require "stimulus-rails"
module Blorgh
class Engine < ::Rails::Engine
isolate_namespace Blorgh
initializer "blorgh.assets" do |app|
app.config.assets.paths << root.join("app/javascript")
end
initializer "blorgh.importmap", before: "importmap" do |app|
app.config.importmap.paths << root.join("config/importmap.rb")
app.config.importmap.cache_sweepers << root.join("app/javascript")
end
end
end
แต่ละ initializer ทำอะไร:
assets.paths << root.join("app/javascript")— เพิ่มโฟลเดอร์ JS ของ engine เข้า asset pipeline ของ host app ทำให้ importmap หา resolveimport "blorgh/application"ไปที่ไฟล์จริงเจอimportmap.paths << root.join("config/importmap.rb")— registerimportmap.rbของ engine เข้ากับ importmap ของ host app ตอน boot pin ของ engine จะรวมเข้ากับ pin ของ host app เป็นชุดเดียวcache_sweepers << root.join("app/javascript")— ให้ importmap reload cache เมื่อไฟล์ใน engine controllers folder เปลี่ยนใน dev mode (ไม่งั้นต้อง restart server ทุกครั้งที่แก้ controller)before: "importmap"— เรียก initializer นี้ก่อน initializer ของ importmap-rails เพื่อให้ path ของ engine ถูก register ทันก่อน importmap จะอ่าน config
JavaScript entrypoint
ใช้ pattern เดียวกับ Rails default — แยก 3 ไฟล์:
1
2
import "@hotwired/turbo-rails"
import "controllers"
entrypoint หลัก โหลด Turbo และ trigger การ load controllers ทั้งหมด
1
2
3
4
5
6
7
8
9
import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }
bootstrap Stimulus app instance ตั้ง window.Stimulus ไว้ debug จาก console ได้
1
2
3
4
// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eager load controller ทั้งหมดที่ pin ไว้ภายใต้ namespace controllers
config/importmap.rb ของ engine
1
2
3
4
5
pin "blorgh/application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from Blorgh::Engine.root.join("app/javascript/blorgh/controllers"), under: "controllers", to: "blorgh/controllers"
pin "blorgh/application"— pin entrypoint ของ engine ด้วย namespaceblorgh/กันชนกับapplicationของ host apppin "@hotwired/turbo-rails", to: "turbo.min.js"— ชี้ logical name ไปที่ asset ที่ turbo-rails gem provide ผ่าน asset pipelinepin_all_from ... under: "controllers", to: "blorgh/controllers"— pin ทุกไฟล์ในapp/javascript/blorgh/controllers/โดยใช้ logical namespacecontrollersแต่ resolve ไปที่ path จริงblorgh/controllers/...ตรงนี้แยกชัดระหว่าง name ที่โค้ดใช้กับ path จริงบน disk
Custom helper สำหรับ importmap ของ engine
มาตรฐาน javascript_importmap_tags ใช้ Rails.application.importmap ของ host app ซึ่งจะรวม pin ของ engine เข้ามาแล้วก็จริง แต่ถ้าอยากให้ engine render importmap ของตัวเองเป็นบล็อกแยก (แยก namespace ชัดเจน เผื่อหลาย engine แต่ละตัวมี Hotwire version ต่างกัน) ต้องสร้าง Importmap::Map instance ของ engine เอง:
1
2
3
4
5
6
7
8
9
10
module Blorgh
class Configuration
attr_accessor :importmap
def initialize
@importmap = Importmap::Map.new
@importmap.draw(Engine.root.join("config/importmap.rb"))
end
end
end
ผูก configuration เข้ากับ namespace ของ engine แบบ Configurable pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require "blorgh/version"
require "blorgh/engine"
require "blorgh/configuration"
module Blorgh
class << self
attr_writer :configuration
def configuration
@configuration ||= Configuration.new
end
def configure
yield(configuration) if block_given?
end
end
end
แล้วเขียน helper ที่ render importmap ของ engine ออกมาเองในสามส่วน:
1
2
3
4
5
6
7
def blorgh_importmap_tags(entry_point = "application")
safe_join [
javascript_inline_importmap_tag(Blorgh.configuration.importmap.to_json(resolver: self)),
javascript_importmap_module_preload_tags(Blorgh.configuration.importmap),
javascript_import_module_tag(entry_point)
].compact, "\n"
end
javascript_inline_importmap_tag— render<script type="importmap">{...}</script>ที่บอก browser ว่า bare specifier ("controllers","@hotwired/turbo-rails") map ไปที่ URL ไหนjavascript_importmap_module_preload_tags— render<link rel="modulepreload">ให้ทุก pin ที่ตั้งpreload: truebrowser จะ fetch ขนานกันแทนรอ chainjavascript_import_module_tag(entry_point)— render<script type="module">import "blorgh/application"</script>เพื่อ trigger การโหลด entrypoint
Layout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<title>Blorgh</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%= stylesheet_link_tag "blorgh/application", media: "all" %>
+ <%= blorgh_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
Controller, route, ทดสอบ Stimulus
รัน command ต่อไปนี้จากใน folder ของ engine:
1
rails g controller home index
ตั้ง root route:
1
2
3
Blorgh::Engine.routes.draw do
root "home#index"
end
สร้าง Stimulus controller ทดสอบ:
1
2
3
4
5
6
7
8
9
10
11
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="engine"
export default class extends Controller {
connect() {
console.log("Connected")
this.element.textContent = "Hello World! This is a Javascript from the Engine"
}
}
console.log("Loaded")
ผูกกับ markup:
1
2
<h1>Engine's Stimulus controller (Engine)</h1>
<div data-controller="engine"></div>
สร้าง host app และ mount
1
rails new demo
1
gem "blorgh", path: "path/to/engine"
1
mount Blorgh::Engine => "/blorgh"
เปิด browser ที่ http://localhost:3000/blorgh จะเห็น Stimulus controller ทำงานและเปลี่ยน text เป็น “Hello World! This is a Javascript from the Engine”
Tailwind CSS
วิธีนี้ engine build CSS ของตัวเองแยกออกมาเป็น app/assets/builds/blorgh.css แล้ว host app จะ pick up ผ่าน asset pipeline ด้วย stylesheet_link_tag "blorgh" ตามปกติ — ไม่ต้องตั้งค่าฝั่ง host app เพิ่ม
เพิ่ม dependency ใน gemspec:
1
spec.add_dependency "tailwindcss-rails"
แก้ layout ให้โหลด stylesheet ของ engine:
1
<%= stylesheet_link_tag "blorgh", "data-turbo-track": "reload" %>
ทดสอบใส่ class:
1
2
<h1 class="text-red-500">Engine's Stimulus controller (Engine)</h1>
<div data-controller="engine"></div>
Copy template ของ Tailwind config และ entrypoint CSS มาจาก gem ของ tailwindcss-rails:
1
2
cp $(bundle show tailwindcss-rails)/lib/install/tailwind.config.js config/tailwind.config.js
cp $(bundle show tailwindcss-rails)/lib/install/application.tailwind.css app/assets/stylesheets/blorgh/application.tailwind.css
ที่ใช้ cp แทน rails tailwindcss:install เพราะ generator ของ gem มุ่งติดตั้งใน Rails app เต็มรูปแบบ ไม่ใช่ใน engine — copy template มา edit เองตรงไปตรงมากว่า
Build CSS:
1
$(bundle show tailwindcss-ruby)/exe/tailwindcss -i app/assets/stylesheets/blorgh/application.tailwind.css -o app/assets/builds/blorgh.css -c config/tailwind.config.js --minify
Watch ตอนพัฒนา:
1
$(bundle show tailwindcss-ruby)/exe/tailwindcss -i app/assets/stylesheets/blorgh/application.tailwind.css -o app/assets/builds/blorgh.css -c config/tailwind.config.js --minify -w
ตรวจ content array ใน config/tailwind.config.js ให้ครอบไฟล์ของ engine ครบ (app/views/**/*.html.erb, app/javascript/**/*.js, ฯลฯ) ไม่งั้น class จะถูก purge ออกตอน build
Troubleshooting
Stimulus controller ไม่เชื่อม (data-controller="engine" ไม่ทำงาน) ตรวจ console ใน browser ว่ามี error Cannot find module 'controllers/engine_controller' หรือไม่ ถ้ามีแสดงว่า pin_all_from ใน config/importmap.rb ไม่ตรงกับ path จริง หรือลืม eagerLoadControllersFrom("controllers", ...) ใน controllers/index.js
Uncaught Error: Cannot find module ตอนโหลดหน้า มี import ในโค้ดที่ยังไม่ได้ pin เพิ่มใน config/importmap.rb ของ engine และ restart server
Tailwind class ใน view ของ engine ไม่ generate content ใน config/tailwind.config.js ครอบไม่ทั่ว — ตรวจให้รวม ./app/views/**/*.html.erb, ./app/helpers/**/*.rb, ./app/javascript/**/*.js ของ engine
Importmap ของ engine ชนกับ host app ถ้าใช้ blorgh_importmap_tags แล้วยังเจอ ตรวจว่า host app’s layout ไม่ได้ render javascript_importmap_tags ของตัวเองในหน้าของ engine (อาจซ้อนกัน) — engine มี layout แยก ใช้ app/views/layouts/blorgh/application.html.erb ของตัวเองโดย default
Conclusion
วิธีนี้ทำให้ engine มี JS และ CSS เป็นของตัวเองโดยไม่ต้องพึ่ง Node toolchain — เหมาะกับ engine ที่ Hotwire-centric (Turbo + Stimulus + เปลี่ยน DOM บางส่วน) และอยากเก็บ build pipeline ให้น้อยที่สุด
จะเลือก importmap หรือ Vite ขึ้นอยู่กับ:
- เลือก importmap ถ้า — JS น้อย, ใช้ Hotwire เป็นหลัก, ไม่อยากมี Node ใน toolchain, deploy ง่ายเป็น priority
- เลือก Vite ถ้า — ใช้ Vue/React/Svelte, ต้องการ HMR, มี dependency JS เยอะที่ต้อง bundle, transpile TypeScript/JSX
References
- importmap-rails README
- tailwindcss-rails README
- Getting Started with Engines (Rails Guides)
- https://mariochavez.io/desarrollo/2023/08/23/working-with-rails-engines-importmap-tailwindcss/
- https://stackoverflow.com/questions/71232601/how-to-use-tailwind-css-gem-in-a-rails-7-engine
- https://radanskoric.com/articles/rails-assets-combine-importmaps

