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.
data/README.md ADDED
@@ -0,0 +1,464 @@
1
+ # vue_live
2
+
3
+ [![CI](https://github.com/danielpclark/vue-live/actions/workflows/ci.yml/badge.svg)](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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+ require 'vue_live/cli'
6
+
7
+ exit VueLive::CLI.start(ARGV)
@@ -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