vue_live 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE-APACHE +201 -0
- data/LICENSE-MIT +21 -0
- data/README.md +464 -0
- data/exe/vue-live +7 -0
- data/lib/generators/vue_live/install/install_generator.rb +50 -0
- data/lib/vue_live/assets/reload.js +32 -0
- data/lib/vue_live/cache.rb +111 -0
- data/lib/vue_live/cli.rb +161 -0
- data/lib/vue_live/compiler/node/compile.js +294 -0
- data/lib/vue_live/compiler/node.rb +216 -0
- data/lib/vue_live/compiler/ruby.rb +104 -0
- data/lib/vue_live/compiler.rb +93 -0
- data/lib/vue_live/configuration.rb +219 -0
- data/lib/vue_live/emitter.rb +72 -0
- data/lib/vue_live/errors.rb +22 -0
- data/lib/vue_live/helpers.rb +118 -0
- data/lib/vue_live/live_reload.rb +135 -0
- data/lib/vue_live/middleware.rb +186 -0
- data/lib/vue_live/node_tools.rb +114 -0
- data/lib/vue_live/precompiler.rb +88 -0
- data/lib/vue_live/rails/helper.rb +43 -0
- data/lib/vue_live/railtie.rb +77 -0
- data/lib/vue_live/resolver.rb +66 -0
- data/lib/vue_live/scoped_css.rb +258 -0
- data/lib/vue_live/sfc/descriptor.rb +86 -0
- data/lib/vue_live/sfc/parser.rb +118 -0
- data/lib/vue_live/sinatra.rb +32 -0
- data/lib/vue_live/source_map.rb +100 -0
- data/lib/vue_live/store.rb +143 -0
- data/lib/vue_live/tasks.rake +50 -0
- data/lib/vue_live/tasks.rb +5 -0
- data/lib/vue_live/templates/HelloVueLive.vue +48 -0
- data/lib/vue_live/templates/vue_live.yml +32 -0
- data/lib/vue_live/version.rb +5 -0
- data/lib/vue_live.rb +107 -0
- metadata +292 -0
data/README.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# vue_live
|
|
2
|
+
|
|
3
|
+
[](https://github.com/danielpclark/vue-live/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Serve Vue single-file components straight from Ruby, in production, with no build step.**
|
|
6
|
+
|
|
7
|
+
Drop a `.vue` file into `app/vue/`, render one helper in a view, and the browser gets a native ES
|
|
8
|
+
module. No bundler, no watcher, no Node.js unless you want it.
|
|
9
|
+
|
|
10
|
+
```erb
|
|
11
|
+
<%# app/views/layouts/application.html.erb, once, in <head> %>
|
|
12
|
+
<%= vue_live_import_map_tag %>
|
|
13
|
+
|
|
14
|
+
<%# any view %>
|
|
15
|
+
<%= vue_live_mount_tag 'HelloVueLive.vue', '#app', props: { name: current_user.name }, element: true %>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
* [Why vue_live](#why-vue_live)
|
|
21
|
+
* [Installation](#installation)
|
|
22
|
+
* [Quick start: Rails](#quick-start-rails)
|
|
23
|
+
* [Quick start: Sinatra](#quick-start-sinatra)
|
|
24
|
+
* [Quick start: plain Rack](#quick-start-plain-rack)
|
|
25
|
+
* [Writing components](#writing-components)
|
|
26
|
+
* [Pinia, Vue Router and other packages](#pinia-vue-router-and-other-packages)
|
|
27
|
+
* [Helpers](#helpers)
|
|
28
|
+
* [Compiler backends](#compiler-backends)
|
|
29
|
+
* [Development: live reload, errors, source maps](#development-live-reload-errors-source-maps)
|
|
30
|
+
* [Caching, production and deployment](#caching-production-and-deployment)
|
|
31
|
+
* [Browser support and trade-offs](#browser-support-and-trade-offs)
|
|
32
|
+
* [Vue 2](#vue-2)
|
|
33
|
+
* [Configuration reference](#configuration-reference)
|
|
34
|
+
* [Command line and rake tasks](#command-line-and-rake-tasks)
|
|
35
|
+
* [Security notes](#security-notes)
|
|
36
|
+
* [Contributing](#contributing)
|
|
37
|
+
* [License](#license)
|
|
38
|
+
|
|
39
|
+
## Why vue_live
|
|
40
|
+
|
|
41
|
+
Back when Rails shipped Sprockets and Webpacker, a `.vue` file could be translated on the fly and
|
|
42
|
+
served to a running site. `vue_live` brings that back for modern Vue (3.x) and modern browsers,
|
|
43
|
+
without tying itself to Rails or to any asset pipeline.
|
|
44
|
+
|
|
45
|
+
* **Pure Ruby compiler, zero runtime dependencies.** A `.vue` file is parsed in Ruby, its
|
|
46
|
+
`<template>` is handed to Vue's own in-browser compiler, `<style scoped>` selectors are rewritten
|
|
47
|
+
the way `@vue/compiler-sfc` does, and the result is served as a native ES module.
|
|
48
|
+
* **Works anywhere Rack does.** Rails, Sinatra, Roda, Hanami, plain `config.ru`.
|
|
49
|
+
* **Recognises a Rails application and configures itself.** A Railtie mounts the middleware,
|
|
50
|
+
adds view helpers, reads `config/vue_live.yml`, registers rake tasks and a generator, and pins
|
|
51
|
+
`vue` into importmap-rails when present.
|
|
52
|
+
* **Coexists with your asset manager.** Components live in `app/vue/` and are served from `/vue/`,
|
|
53
|
+
so Sprockets, Propshaft, Webpacker, jsbundling and importmap-rails keep their own directories and
|
|
54
|
+
URLs. Nothing is registered with them and nothing of theirs is overridden.
|
|
55
|
+
* **Optional Node backend.** Point it at a project with `@vue/compiler-sfc` installed and
|
|
56
|
+
`<script setup>`, TypeScript, Pug, Sass/Less/Stylus and CSS modules work too. The `auto`
|
|
57
|
+
strategy only uses Node for components that need it.
|
|
58
|
+
* **Production ready.** Compiled once per process (or persisted to disk), served with ETags,
|
|
59
|
+
digested URLs with far-future caching for the whole import graph, and CSP-friendly style
|
|
60
|
+
injection. Optionally precompile everything to static files for a CDN.
|
|
61
|
+
* **Pleasant in development.** Changed components recompile on the next request, the page
|
|
62
|
+
reloads itself, compile errors show up in the page, and inline source maps point browser
|
|
63
|
+
errors at the right line of the `.vue` file.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Gemfile
|
|
69
|
+
gem 'vue_live'
|
|
70
|
+
# Until the first release is on RubyGems:
|
|
71
|
+
# gem 'vue_live', github: 'danielpclark/vue-live'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires Ruby 3.0+. Node.js is **not** required unless you opt into the
|
|
75
|
+
[Node backend](#compiler-backends).
|
|
76
|
+
|
|
77
|
+
## Quick start: Rails
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
bin/rails generate vue_live:install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The generator writes `config/vue_live.yml` and an example `app/vue/HelloVueLive.vue`. Pass
|
|
84
|
+
`--node` to also install the Node backend, or `--skip-example` to leave `app/vue/` empty.
|
|
85
|
+
|
|
86
|
+
Render the import map once, in your layout's `<head>`, and mount components from any view:
|
|
87
|
+
|
|
88
|
+
```erb
|
|
89
|
+
<%# app/views/layouts/application.html.erb %>
|
|
90
|
+
<%= vue_live_import_map_tag %> <%# omit when you use importmap-rails, see below %>
|
|
91
|
+
|
|
92
|
+
<%# app/views/pages/home.html.erb %>
|
|
93
|
+
<%= vue_live_mount_tag 'HelloVueLive.vue', '#app', props: { name: current_user.name }, element: true %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That is the whole setup. `app/vue/HelloVueLive.vue` is compiled on first request and served at
|
|
97
|
+
`/vue/HelloVueLive.vue.js`. In development it recompiles whenever the file changes and the page
|
|
98
|
+
reloads itself.
|
|
99
|
+
|
|
100
|
+
Settings live in `config/vue_live.yml` (written by the generator) or in Ruby, which takes
|
|
101
|
+
precedence:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# config/application.rb
|
|
105
|
+
config.vue_live.source_path = 'app/components' # default: app/vue
|
|
106
|
+
config.vue_live.prefix = '/components' # default: /vue
|
|
107
|
+
config.vue_live.compiler = :node # :auto (default), :ruby or :node
|
|
108
|
+
config.vue_live.cache = :file # :memory (default), :file or :none
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Rails + importmap-rails
|
|
112
|
+
|
|
113
|
+
Browsers honour only one `<script type="importmap">` per page. When importmap-rails is present,
|
|
114
|
+
`vue_live_import_map_tag` renders nothing and the Railtie pins `vue` into *your* import map
|
|
115
|
+
instead. Keep using `javascript_importmap_tags`. Set `config.vue_live.importmap_pin = false` to
|
|
116
|
+
opt out and manage the two separately.
|
|
117
|
+
|
|
118
|
+
### Rails + Sprockets / Propshaft / Webpacker / jsbundling
|
|
119
|
+
|
|
120
|
+
Nothing to do. `vue_live` never touches `app/assets`, `app/javascript`, `/assets` or `/packs`,
|
|
121
|
+
does not register `.vue` with Sprockets, and `vue_live:precompile` is **not** hooked into
|
|
122
|
+
`assets:precompile` unless you set `config.vue_live.hook_assets_precompile = true`.
|
|
123
|
+
The middleware is inserted before `ActionDispatch::Static` and only answers requests under its
|
|
124
|
+
prefix; everything else passes straight through.
|
|
125
|
+
|
|
126
|
+
## Quick start: Sinatra
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
require 'sinatra/base'
|
|
130
|
+
require 'vue_live/sinatra'
|
|
131
|
+
|
|
132
|
+
class App < Sinatra::Base
|
|
133
|
+
set :vue_live, source_path: 'app/vue', prefix: '/vue' # optional, must come before register
|
|
134
|
+
register VueLive::Sinatra
|
|
135
|
+
|
|
136
|
+
get '/' do
|
|
137
|
+
<<~HTML
|
|
138
|
+
<!doctype html>
|
|
139
|
+
#{vue_live_import_map_tag}
|
|
140
|
+
#{vue_live_mount_tag 'App.vue', '#app', props: { title: 'Sinatra' }, element: true}
|
|
141
|
+
HTML
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`register` reads `settings.vue_live`, mounts the middleware and adds the helpers, so any
|
|
147
|
+
`set :vue_live` has to come first. `config/vue_live.yml` is read as well, if present.
|
|
148
|
+
Classic-style apps (`require 'sinatra'`) call `register VueLive::Sinatra` at the top level.
|
|
149
|
+
|
|
150
|
+
`vue-live init` scaffolds `config/vue_live.yml` and `app/vue/HelloVueLive.vue` in any project.
|
|
151
|
+
|
|
152
|
+
## Quick start: plain Rack
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# config.ru
|
|
156
|
+
require 'vue_live'
|
|
157
|
+
VueLive.configure { |c| c.source_path = 'app/vue' }
|
|
158
|
+
VueLive.load_config_file # config/vue_live.yml, if any
|
|
159
|
+
|
|
160
|
+
use VueLive::Middleware
|
|
161
|
+
run MyApp
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Include `VueLive::Helpers` wherever you render HTML to get the tag helpers.
|
|
165
|
+
|
|
166
|
+
## Writing components
|
|
167
|
+
|
|
168
|
+
A component is an ordinary Vue SFC. Relative imports to other components and to plain `.js`
|
|
169
|
+
files under the component root just work:
|
|
170
|
+
|
|
171
|
+
```vue
|
|
172
|
+
<template>
|
|
173
|
+
<button class="counter" @click="count++">{{ label }}: {{ count }}</button>
|
|
174
|
+
<Child />
|
|
175
|
+
</template>
|
|
176
|
+
|
|
177
|
+
<script>
|
|
178
|
+
import Child from './nested/Child.vue'
|
|
179
|
+
import { shout } from './shared/util.js'
|
|
180
|
+
|
|
181
|
+
export default {
|
|
182
|
+
components: { Child },
|
|
183
|
+
props: { label: String },
|
|
184
|
+
data() { return { count: 0 } },
|
|
185
|
+
methods: { yell() { alert(shout(this.label)) } }
|
|
186
|
+
}
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<style scoped>
|
|
190
|
+
.counter { color: #42b883; }
|
|
191
|
+
</style>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
With the Ruby backend the served module is, in essence:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
import Child from './nested/Child.vue.js?v=9c1e…'
|
|
198
|
+
import { shout } from './shared/util.js?v=1718-412'
|
|
199
|
+
const __sfc__ = { components: { Child }, props: { label: String }, data() { return { count: 0 } }, methods: { … } }
|
|
200
|
+
__sfc__.template = "<button class=\"counter\" @click=\"count++\">{{ label }}: {{ count }}</button>\n<Child />"
|
|
201
|
+
__sfc__.__scopeId = "data-v-5d1a9f3c"
|
|
202
|
+
export default __sfc__
|
|
203
|
+
/* + a few lines that register ".counter[data-v-5d1a9f3c] { color: #42b883 }" once */
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Vue compiles the template string in the browser (so the import map must point at a *full* build,
|
|
207
|
+
`vue.esm-browser.js`, which is the default) and applies the scope id in its renderer, so scoped
|
|
208
|
+
styles need no build step either.
|
|
209
|
+
|
|
210
|
+
**Shared files.** Anything under `source_path` with an allowed extension is served as-is from the
|
|
211
|
+
same prefix: `.js` and `.mjs` modules, `.css`, `.json`, images and fonts. So `app/vue/shared/util.js`
|
|
212
|
+
is importable from any component and `app/vue/img/logo.svg` is reachable at `/vue/img/logo.svg`.
|
|
213
|
+
Blocks can also point at files with `src="..."`, e.g. `<style src="./shared/theme.css">`.
|
|
214
|
+
|
|
215
|
+
**Several components on one page.** Render `vue_live_import_map_tag` once, in the layout, and as
|
|
216
|
+
many `vue_live_mount_tag`s as you like with different selectors. Each becomes its own small Vue
|
|
217
|
+
app:
|
|
218
|
+
|
|
219
|
+
```erb
|
|
220
|
+
<%= vue_live_mount_tag 'Search.vue', '#search', element: true %>
|
|
221
|
+
<%= vue_live_mount_tag 'Cart.vue', '#cart', props: { items: @cart.as_json }, element: true %>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Props** are passed as JSON, so anything `JSON.generate` accepts works. `element: true` renders
|
|
225
|
+
the mount `<div>` for you; leave it off when the element is already in your markup.
|
|
226
|
+
|
|
227
|
+
## Pinia, Vue Router and other packages
|
|
228
|
+
|
|
229
|
+
Components can import any bare specifier that the page's import map resolves. Add entries with
|
|
230
|
+
`import_map` in `config/vue_live.yml` (or `config.vue_live.import_map`), or per page with
|
|
231
|
+
`vue_live_import_map_tag(imports: { ... })`:
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
default:
|
|
235
|
+
import_map:
|
|
236
|
+
pinia: https://esm.sh/pinia@3?external=vue
|
|
237
|
+
vue-router: https://esm.sh/vue-router@4?external=vue
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`?external=vue` keeps the package's own `import ... from 'vue'` bare, so it resolves through the
|
|
241
|
+
import map to the same Vue instance your components use. Any CDN works as long as the build you
|
|
242
|
+
pick does not bundle its own copy of Vue. With importmap-rails, pin the packages there instead.
|
|
243
|
+
|
|
244
|
+
Then write the mount script yourself with `vue_live_module_tag` and `vue_live_path`:
|
|
245
|
+
|
|
246
|
+
```erb
|
|
247
|
+
<div id="app"></div>
|
|
248
|
+
<%= vue_live_module_tag "
|
|
249
|
+
import { createApp } from 'vue'
|
|
250
|
+
import { createPinia } from 'pinia'
|
|
251
|
+
import App from '#{vue_live_path('App.vue')}'
|
|
252
|
+
|
|
253
|
+
createApp(App).use(createPinia()).mount('#app')
|
|
254
|
+
" %>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Inside components, `import { defineStore } from 'pinia'` and `import { useRouter } from 'vue-router'`
|
|
258
|
+
work exactly as they would under a bundler. Under Rails the module tag picks up the CSP nonce
|
|
259
|
+
automatically.
|
|
260
|
+
|
|
261
|
+
## Helpers
|
|
262
|
+
|
|
263
|
+
| Helper | Purpose |
|
|
264
|
+
| --- | --- |
|
|
265
|
+
| `vue_live_import_map_tag(imports: {})` | `<script type="importmap">` mapping `vue` plus `config.import_map` and `imports`. Once per page, before any module script. Renders nothing under importmap-rails. |
|
|
266
|
+
| `vue_live_mount_tag(component, selector = '#app', props: {}, element: false, plugins: [], nonce: nil)` | `<script type="module">` that imports the component and mounts it on `selector`. Adds the live-reload client in development. |
|
|
267
|
+
| `vue_live_module_tag(js)` | `<script type="module">` for your own code (custom mounts, routers, stores). |
|
|
268
|
+
| `vue_live_path(component)` | `/vue/App.vue.js?v=<digest>`, read from the manifest when precompiled. |
|
|
269
|
+
| `vue_live_tags(component, selector, **)` | Import map + mount tag in one call, for single-component pages. |
|
|
270
|
+
| `vue_live_reload_tag` | The live-reload client on its own. Renders nothing when `live_reload` is off. |
|
|
271
|
+
|
|
272
|
+
`plugins:` takes JavaScript expressions that are appended as `app.use(...)` calls. The generated
|
|
273
|
+
script imports only `vue` and the component, so this suits globals you have already loaded; for
|
|
274
|
+
anything that needs its own import, write the mount script with `vue_live_module_tag` as shown
|
|
275
|
+
above.
|
|
276
|
+
|
|
277
|
+
Under Rails the helpers return `html_safe` strings and pick up the CSP nonce automatically.
|
|
278
|
+
Outside Rails they return plain strings; pass `nonce:` yourself if you use a CSP.
|
|
279
|
+
|
|
280
|
+
## Compiler backends
|
|
281
|
+
|
|
282
|
+
| | `:ruby` | `:node` |
|
|
283
|
+
| --- | --- | --- |
|
|
284
|
+
| Dependencies | none | Node.js + `@vue/compiler-sfc` in the project |
|
|
285
|
+
| `<template>` | string, compiled in the browser (needs Vue's full build) | render function (runtime-only Vue build is enough) |
|
|
286
|
+
| `<script>` | yes | yes |
|
|
287
|
+
| `<script setup>` | no | yes |
|
|
288
|
+
| `<script lang="ts">` | no | yes (types stripped by Node >= 22.13 itself, or by sucrase, esbuild, Babel or TypeScript < 7) |
|
|
289
|
+
| `<style>`, `<style scoped>` | yes (`:deep`, `:slotted`, `:global`) | yes |
|
|
290
|
+
| `<style lang="scss">`, `<style module>`, `<template lang="pug">` | no | yes, with the npm packages installed |
|
|
291
|
+
| `src="..."` on blocks | yes | yes |
|
|
292
|
+
| Speed | ~1 ms per component | ~400 ms once to start the worker, then a few ms per component |
|
|
293
|
+
| Source maps | script block, line for line | script and template, merged |
|
|
294
|
+
|
|
295
|
+
`compiler: auto` (the default) uses Ruby and falls back to Node only for components that need it,
|
|
296
|
+
with a clear error naming the feature when Node is unavailable.
|
|
297
|
+
|
|
298
|
+
The Node backend keeps one `node compile.js --server` worker per configuration, so after the
|
|
299
|
+
first compile (which includes Node's start-up) each component takes a few milliseconds. The
|
|
300
|
+
worker is restarted automatically if it dies; set `node_worker: false` to spawn a process per
|
|
301
|
+
compile instead.
|
|
302
|
+
|
|
303
|
+
Enable the Node backend with:
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
vue-live node-setup # npm/yarn add @vue/compiler-sfc vue
|
|
307
|
+
vue-live node-setup --with sass # plus preprocessors you use
|
|
308
|
+
bin/rails vue_live:node_setup # the same, under Rails
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Installing `vue` locally also makes `vue_live` serve it from `/vue/-/vue.esm-browser.js` instead
|
|
312
|
+
of the CDN. If the [webpacker_cli](https://github.com/danielpclark/webpacker-cli) gem is
|
|
313
|
+
installed its package manager detection is reused, so projects already built with it need
|
|
314
|
+
nothing extra.
|
|
315
|
+
|
|
316
|
+
**TypeScript.** Node.js 22.13+ strips types itself. On older Node.js a transpiler package is
|
|
317
|
+
needed; `vue-live node-setup` adds [sucrase](https://github.com/alangpierce/sucrase) automatically
|
|
318
|
+
in that case (pure JavaScript, keeps line numbers so source maps stay exact).
|
|
319
|
+
`VUE_LIVE_TS_TRANSPILER=node|sucrase|esbuild|typescript|babel` pins one when several are
|
|
320
|
+
installed, and `vue-live check` warns when a Node.js that cannot strip types has no transpiler.
|
|
321
|
+
|
|
322
|
+
## Development: live reload, errors, source maps
|
|
323
|
+
|
|
324
|
+
* **Live reload.** With `live_reload` on (the default whenever `reload` is on) `vue_live_mount_tag`
|
|
325
|
+
adds a small client that listens to a Server-Sent Events stream at `/vue/-/events` and reloads
|
|
326
|
+
the page when any file under the component root changes. The stream is served by the
|
|
327
|
+
middleware from the request's own thread, so it works with Puma, Falcon, WEBrick or anything
|
|
328
|
+
else threaded, with no extra process. `vue_live_reload_tag` renders the client on its own.
|
|
329
|
+
* **Errors in the page.** A component that fails to compile is served as a module that logs the
|
|
330
|
+
error, shows it in an overlay, and throws, so the failure is visible without opening the log.
|
|
331
|
+
* **Source maps.** With `source_maps` on (the default outside production) every module ends with
|
|
332
|
+
an inline source map. The Ruby backend maps the `<script>` block line for line; the Node
|
|
333
|
+
backend merges `@vue/compiler-sfc`'s script and template maps, so stack traces and breakpoints
|
|
334
|
+
land in the `.vue` file.
|
|
335
|
+
|
|
336
|
+
## Caching, production and deployment
|
|
337
|
+
|
|
338
|
+
* `reload` (default: on outside production) compares mtimes on every request and recompiles
|
|
339
|
+
changed files, including `src="..."` dependencies and imported siblings.
|
|
340
|
+
* `digest_imports` (default: on) rewrites the relative imports inside a module to
|
|
341
|
+
`./Child.vue.js?v=<digest>` and `./util.js?v=<mtime-size>`, so a page's whole module graph can
|
|
342
|
+
be served with `Cache-Control: immutable`. A child's digest is part of its parent's code, so a
|
|
343
|
+
change anywhere propagates up to the URL the page requests. Import cycles are handled (the
|
|
344
|
+
back edge stays undigested and revalidates by ETag).
|
|
345
|
+
* `cache: :memory` (default) keeps compiled modules per process. `cache: :file` also writes them
|
|
346
|
+
to `tmp/cache/vue_live` so Puma workers and restarts share the work.
|
|
347
|
+
* Responses carry an `ETag`; URLs from `vue_live_path` include `?v=<digest>` and are served with
|
|
348
|
+
`Cache-Control: public, max-age=31536000, immutable` in production.
|
|
349
|
+
* In production a component that fails to compile is a 500 with the details in the log.
|
|
350
|
+
* `vue-live compile` / `bin/rails vue_live:precompile` writes every component as a static
|
|
351
|
+
`.vue.js` file plus `manifest.json` to `public/vue`, for a CDN or `nginx`. When the manifest
|
|
352
|
+
exists in production, `vue_live_path` reads URLs from it, so a web server or CDN in front of
|
|
353
|
+
`public/` serves the files and the app never compiles them. Without one, the middleware still
|
|
354
|
+
answers from its cache.
|
|
355
|
+
|
|
356
|
+
**Deploying.** Three setups work, pick the one that fits your host:
|
|
357
|
+
|
|
358
|
+
| Setup | What to do | Good for |
|
|
359
|
+
| --- | --- | --- |
|
|
360
|
+
| Compile at runtime, memory cache | Nothing. Each process compiles on first request. | Read-only filesystems, small apps, few processes |
|
|
361
|
+
| Compile at runtime, file cache | `cache: file` in `config/vue_live.yml`; `tmp/` must be writable. | Puma clusters, frequent restarts |
|
|
362
|
+
| Precompile | Run `bin/rails vue_live:precompile` in your build (Dockerfile, CI, or `hook_assets_precompile: true`). | CDNs, `nginx` serving `public/`, zero compile cost at runtime |
|
|
363
|
+
|
|
364
|
+
In production the CDN URL switches to `vue.esm-browser.prod.js` automatically, and
|
|
365
|
+
`vue-live check` / `bin/rails vue_live:check` verifies that every component compiles before you ship.
|
|
366
|
+
|
|
367
|
+
## Browser support and trade-offs
|
|
368
|
+
|
|
369
|
+
`vue_live` relies on two browser features: native ES modules and import maps. Import maps are
|
|
370
|
+
supported in Chrome and Edge 89+, Firefox 108+ and Safari 16.4+ (March 2023). Older browsers can
|
|
371
|
+
be covered with [es-module-shims](https://github.com/guybedford/es-module-shims) if you need them.
|
|
372
|
+
|
|
373
|
+
Serving modules unbundled is a deliberate trade:
|
|
374
|
+
|
|
375
|
+
* **One request per module.** Fine over HTTP/2 for a page that loads a few dozen files; a
|
|
376
|
+
component tree in the hundreds is better served precompiled behind a CDN, or bundled.
|
|
377
|
+
* **No tree shaking or minification of your own code.** Vue itself comes minified from the CDN;
|
|
378
|
+
your components are served as written.
|
|
379
|
+
* **The Ruby backend compiles templates in the browser.** That needs Vue's full build, which is
|
|
380
|
+
larger than the runtime-only build, and costs a little CPU on first render. The Node backend
|
|
381
|
+
precompiles templates to render functions and removes both costs.
|
|
382
|
+
|
|
383
|
+
If you are already running Vite or esbuild and are happy with it, keep it. `vue_live` is for
|
|
384
|
+
the many apps where a build step is the only reason Node.js is installed.
|
|
385
|
+
|
|
386
|
+
## Vue 2
|
|
387
|
+
|
|
388
|
+
The Ruby backend's output is also valid for Vue 2.7's full build (`_scopeId` is emitted alongside
|
|
389
|
+
`__scopeId`). Set `vue_version: 2.7.16` and `vue_url` to a Vue 2 ESM build and
|
|
390
|
+
`vue_live_mount_tag` emits `new Vue({ render: h => h(App) }).$mount(...)`. Vue 2 is end-of-life,
|
|
391
|
+
so Vue 3 is the default and the only version the Node backend supports.
|
|
392
|
+
|
|
393
|
+
## Configuration reference
|
|
394
|
+
|
|
395
|
+
Values come from built-in defaults, then `config/vue_live.yml` (the `default` section merged with
|
|
396
|
+
the current environment's section), then Ruby (`VueLive.configure` or `config.vue_live`).
|
|
397
|
+
|
|
398
|
+
| Key | Default | Meaning |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| `root` | `Rails.root` / `Dir.pwd` | project root |
|
|
401
|
+
| `source_path` | `app/vue` | component directory, relative to root |
|
|
402
|
+
| `prefix` | `/vue` | URL prefix |
|
|
403
|
+
| `compiler` | `auto` | `ruby`, `node` or `auto` |
|
|
404
|
+
| `reload` | not production | recompile on file change |
|
|
405
|
+
| `cache` / `cache_path` | `memory` / `tmp/cache/vue_live` | `memory`, `file`, `none` |
|
|
406
|
+
| `vue_url` | auto | local copy if present, else pinned jsDelivr build (`.prod.js` in production) |
|
|
407
|
+
| `vue_version` | pinned 3.x | version used for the CDN URL and Vue 2 detection |
|
|
408
|
+
| `import_map` | `{}` | extra import-map entries |
|
|
409
|
+
| `extensions` | `.vue .js .mjs .css .json` + images/fonts | files the middleware will serve from `source_path` |
|
|
410
|
+
| `node_bin` | `node` | Node executable |
|
|
411
|
+
| `precompile_path` | `public/vue` | output of `vue-live compile` |
|
|
412
|
+
| `use_manifest` | auto | read `public/vue/manifest.json` for URLs (auto: in production when it exists) |
|
|
413
|
+
| `middleware` | `true` | mount automatically (Railtie / Sinatra); set `false` to `use` it yourself |
|
|
414
|
+
| `importmap_pin` | `true` | pin `vue` into importmap-rails |
|
|
415
|
+
| `hook_assets_precompile` | `false` | run `vue_live:precompile` with `assets:precompile` |
|
|
416
|
+
| `inject_styles` | `true` | emit `<style>` blocks into the module |
|
|
417
|
+
| `source_maps` | not production | append an inline source map to each module |
|
|
418
|
+
| `node_worker` | `true` | keep one long-lived Node worker instead of a process per compile |
|
|
419
|
+
| `live_reload` / `live_reload_interval` | follows `reload` / `0.5` | SSE live reload and its scan interval in seconds |
|
|
420
|
+
| `digest_imports` | `true` | add `?v=<digest>` to relative imports inside modules |
|
|
421
|
+
|
|
422
|
+
Environment variables:
|
|
423
|
+
|
|
424
|
+
| Variable | Meaning |
|
|
425
|
+
| --- | --- |
|
|
426
|
+
| `VUE_LIVE_ENV`, `RAILS_ENV`, `RACK_ENV`, `APP_ENV` | environment name, first one set wins (default `development`) |
|
|
427
|
+
| `VUE_LIVE_NODE` | Node executable, same as `node_bin` |
|
|
428
|
+
| `VUE_LIVE_TS_TRANSPILER` | `node`, `sucrase`, `esbuild`, `typescript` or `babel` |
|
|
429
|
+
|
|
430
|
+
## Command line and rake tasks
|
|
431
|
+
|
|
432
|
+
The gem ships a `vue-live` executable for any project and the same operations as rake tasks
|
|
433
|
+
under Rails:
|
|
434
|
+
|
|
435
|
+
| `vue-live` | Rails | Does |
|
|
436
|
+
| --- | --- | --- |
|
|
437
|
+
| `init [--force] [--node]` | `bin/rails generate vue_live:install [--node] [--skip-example]` | create `config/vue_live.yml` and `app/vue/HelloVueLive.vue` |
|
|
438
|
+
| `compile [--out DIR]` | `bin/rails vue_live:precompile` | write static `.vue.js` modules + `manifest.json` |
|
|
439
|
+
| `clobber` | `bin/rails vue_live:clobber` | remove precompiled output and the compile cache |
|
|
440
|
+
| `check` | `bin/rails vue_live:check` | compile every component and verify the Node toolchain; exits non-zero on problems |
|
|
441
|
+
| `node-setup [--with pkg,pkg]` | `bin/rails vue_live:node_setup` | install `@vue/compiler-sfc` and `vue` (plus sucrase when Node.js < 22.13) |
|
|
442
|
+
| `info` | | print versions and the resolved settings |
|
|
443
|
+
|
|
444
|
+
## Security notes
|
|
445
|
+
|
|
446
|
+
Everything under `source_path` with an allowed extension is public. Keep secrets out of it.
|
|
447
|
+
Paths are normalised and confined to that directory; dot-files and unknown extensions are refused.
|
|
448
|
+
|
|
449
|
+
## Contributing
|
|
450
|
+
|
|
451
|
+
```sh
|
|
452
|
+
bundle install
|
|
453
|
+
bundle exec rake test:setup # npm install in test/ (+ a Chromium) for the Node-backend and browser tests
|
|
454
|
+
bundle exec rake test # everything; without test:setup the Node and browser tests skip with a message
|
|
455
|
+
bundle exec rubocop
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`VUE_LIVE_NODE_ROOT` points the Node tests at a different `node_modules`; `PLAYWRIGHT_CHROMIUM`
|
|
459
|
+
names a browser when Playwright could not download one; `VUE_LIVE_E2E=0` skips the browser test.
|
|
460
|
+
|
|
461
|
+
## License
|
|
462
|
+
|
|
463
|
+
Dual-licensed under either the [MIT License](LICENSE-MIT) or the
|
|
464
|
+
[Apache License, Version 2.0](LICENSE-APACHE), at your option.
|
data/exe/vue-live
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module VueLive
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate vue_live:install
|
|
8
|
+
#
|
|
9
|
+
# Creates app/vue/ with an example component, config/vue_live.yml and prints how to render it.
|
|
10
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
11
|
+
source_root File.expand_path('../../../vue_live/templates', __dir__)
|
|
12
|
+
|
|
13
|
+
class_option :node, type: :boolean, default: false,
|
|
14
|
+
desc: 'Also install @vue/compiler-sfc and vue with npm/yarn (enables <script setup>, TypeScript, Sass)'
|
|
15
|
+
class_option :skip_example, type: :boolean, default: false, desc: 'Do not create app/vue/HelloVueLive.vue'
|
|
16
|
+
|
|
17
|
+
def create_config
|
|
18
|
+
template 'vue_live.yml', 'config/vue_live.yml'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_component_directory
|
|
22
|
+
empty_directory 'app/vue'
|
|
23
|
+
copy_file 'HelloVueLive.vue', 'app/vue/HelloVueLive.vue' unless options[:skip_example]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def setup_node
|
|
27
|
+
return unless options[:node]
|
|
28
|
+
|
|
29
|
+
require 'vue_live/node_tools'
|
|
30
|
+
VueLive::NodeTools.setup(destination_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def show_readme
|
|
34
|
+
say ''
|
|
35
|
+
say 'vue_live is installed. Render a component from any view:', :green
|
|
36
|
+
say ''
|
|
37
|
+
say ' <%= vue_live_import_map_tag %>' unless defined?(::Importmap)
|
|
38
|
+
say ' <%= vue_live_mount_tag "HelloVueLive.vue", "#app", props: { name: "Rails" }, element: true %>'
|
|
39
|
+
say ''
|
|
40
|
+
if defined?(::Importmap)
|
|
41
|
+
say 'importmap-rails detected: `vue` has been pinned into your import map automatically;'
|
|
42
|
+
say 'keep using javascript_importmap_tags in your layout.'
|
|
43
|
+
say ''
|
|
44
|
+
end
|
|
45
|
+
say 'Components live in app/vue/ and are served live from /vue/<Name>.vue.js.'
|
|
46
|
+
say 'Settings: config/vue_live.yml or config.vue_live.* in config/application.rb.'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// vue_live development live-reload client. Served at <prefix>/-/reload.js, listens to the
|
|
2
|
+
// server-sent events at <prefix>/-/events and reloads the page when a component changes.
|
|
3
|
+
;(function () {
|
|
4
|
+
if (typeof window === 'undefined' || window.__vueLiveReload) return
|
|
5
|
+
window.__vueLiveReload = true
|
|
6
|
+
var script = document.currentScript
|
|
7
|
+
var url = (script && script.getAttribute('data-events')) || '/vue/-/events'
|
|
8
|
+
var source
|
|
9
|
+
var reconnectDelay = 1000
|
|
10
|
+
|
|
11
|
+
function connect() {
|
|
12
|
+
source = new EventSource(url)
|
|
13
|
+
source.addEventListener('change', function (event) {
|
|
14
|
+
try {
|
|
15
|
+
var files = JSON.parse(event.data).files || []
|
|
16
|
+
console.info('[vue_live] changed: ' + files.join(', ') + ' — reloading')
|
|
17
|
+
} catch (e) { /* ignore */ }
|
|
18
|
+
window.location.reload()
|
|
19
|
+
})
|
|
20
|
+
source.addEventListener('open', function () { reconnectDelay = 1000 })
|
|
21
|
+
source.addEventListener('error', function () {
|
|
22
|
+
// The server went away (restart) or the stream timed out; EventSource retries on its own,
|
|
23
|
+
// but a closed source needs a fresh one.
|
|
24
|
+
if (source.readyState === EventSource.CLOSED) {
|
|
25
|
+
setTimeout(connect, reconnectDelay)
|
|
26
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10000)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect()
|
|
32
|
+
})()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module VueLive
|
|
8
|
+
# A compiled component as served to the browser.
|
|
9
|
+
Compiled = Struct.new(:relative_path, :code, :digest, :mtime, :dependencies, :backend, :scope_id, keyword_init: true) do
|
|
10
|
+
def etag
|
|
11
|
+
%("#{digest}")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{ 'relative_path' => relative_path, 'code' => code, 'digest' => digest, 'mtime' => mtime.to_f,
|
|
16
|
+
'dependencies' => dependencies, 'backend' => backend.to_s, 'scope_id' => scope_id }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_h(h)
|
|
20
|
+
new(relative_path: h['relative_path'], code: h['code'], digest: h['digest'], mtime: Time.at(h['mtime'].to_f),
|
|
21
|
+
dependencies: Array(h['dependencies']), backend: h['backend']&.to_sym, scope_id: h['scope_id'])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Cache
|
|
26
|
+
def self.build(config)
|
|
27
|
+
case config.cache.to_s
|
|
28
|
+
when 'none' then Null.new
|
|
29
|
+
when 'file' then FileStore.new(config.cache_dir, Memory.new)
|
|
30
|
+
else Memory.new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Null
|
|
35
|
+
def read(_key) = nil
|
|
36
|
+
def write(_key, value) = value
|
|
37
|
+
def delete(_key) = nil
|
|
38
|
+
def clear = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Memory
|
|
42
|
+
def initialize
|
|
43
|
+
@data = {}
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read(key)
|
|
48
|
+
@mutex.synchronize { @data[key] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write(key, value)
|
|
52
|
+
@mutex.synchronize { @data[key] = value }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def delete(key)
|
|
56
|
+
@mutex.synchronize { @data.delete(key) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clear
|
|
60
|
+
@mutex.synchronize { @data.clear }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Persists compiled modules as JSON files so warm caches survive restarts and are shared
|
|
65
|
+
# between processes (Puma workers, Sidekiq rendering emails...). Wraps a Memory cache.
|
|
66
|
+
class FileStore
|
|
67
|
+
def initialize(dir, inner = Memory.new)
|
|
68
|
+
@dir = dir
|
|
69
|
+
@inner = inner
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read(key)
|
|
73
|
+
hit = @inner.read(key)
|
|
74
|
+
return hit if hit
|
|
75
|
+
|
|
76
|
+
file = path_for(key)
|
|
77
|
+
return nil unless File.file?(file)
|
|
78
|
+
|
|
79
|
+
compiled = Compiled.from_h(JSON.parse(File.read(file)))
|
|
80
|
+
@inner.write(key, compiled)
|
|
81
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def write(key, value)
|
|
86
|
+
@inner.write(key, value)
|
|
87
|
+
FileUtils.mkdir_p(@dir)
|
|
88
|
+
tmp = "#{path_for(key)}.#{Process.pid}.tmp"
|
|
89
|
+
File.write(tmp, JSON.generate(value.to_h))
|
|
90
|
+
File.rename(tmp, path_for(key))
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete(key)
|
|
95
|
+
@inner.delete(key)
|
|
96
|
+
FileUtils.rm_f(path_for(key))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def clear
|
|
100
|
+
@inner.clear
|
|
101
|
+
FileUtils.rm_rf(@dir)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def path_for(key)
|
|
107
|
+
File.join(@dir, "#{Digest::SHA256.hexdigest(key)}.json")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|