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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module VueLive
|
|
6
|
+
# Development live reload: a Server-Sent Events stream at <prefix>/-/events that announces
|
|
7
|
+
# changed components, and a small client (<prefix>/-/reload.js) that reloads the page.
|
|
8
|
+
#
|
|
9
|
+
# The stream polls the component directory's mtimes (config.live_reload_interval seconds) from
|
|
10
|
+
# the request's own thread, so it works with any threaded Rack server (Puma, Falcon, WEBrick)
|
|
11
|
+
# without extra processes or websockets. On Rack 3 it is a streaming body (`call(io)`), which
|
|
12
|
+
# servers write through unbuffered; on Rack 2 it is an `each` body.
|
|
13
|
+
class LiveReload
|
|
14
|
+
CLIENT = File.expand_path('assets/reload.js', __dir__)
|
|
15
|
+
HEARTBEAT = 5 # seconds between keep-alive comments (also how fast a gone client is noticed)
|
|
16
|
+
|
|
17
|
+
attr_reader :config
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enabled?
|
|
24
|
+
config.live_reload?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Rack response for the SSE stream.
|
|
28
|
+
def stream_response(_env = nil)
|
|
29
|
+
headers = {
|
|
30
|
+
'content-type' => 'text/event-stream; charset=utf-8',
|
|
31
|
+
'cache-control' => 'no-store',
|
|
32
|
+
'x-accel-buffering' => 'no' # nginx: do not buffer
|
|
33
|
+
}
|
|
34
|
+
body = LiveReload.rack3? ? StreamingBody.new(config, self) : Stream.new(config, self)
|
|
35
|
+
[200, headers, body]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def client_response
|
|
39
|
+
body = File.read(CLIENT)
|
|
40
|
+
[200, { 'content-type' => 'text/javascript; charset=utf-8', 'cache-control' => 'no-cache',
|
|
41
|
+
'content-length' => body.bytesize.to_s }, [body]]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Snapshot of every servable file's mtime under the component root.
|
|
45
|
+
def snapshot
|
|
46
|
+
resolver = Resolver.new(config)
|
|
47
|
+
root = resolver.source_dir
|
|
48
|
+
resolver.files.each_with_object({}) do |rel, h|
|
|
49
|
+
h[rel] = File.mtime(File.join(root, rel)).to_f
|
|
50
|
+
rescue Errno::ENOENT
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Files whose mtime differs between two snapshots (added, removed or modified).
|
|
56
|
+
def self.diff(before, after)
|
|
57
|
+
(before.keys | after.keys).reject { |k| before[k] == after[k] }.sort
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.rack3?
|
|
61
|
+
defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s.split('.').first.to_i >= 3
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Produces SSE frames until the consumer raises (client gone), +stop+ is called, or the
|
|
65
|
+
# stream reaches MAX_LIFETIME (the client reconnects at once; the cap keeps servers that join
|
|
66
|
+
# request threads on shutdown, such as WEBrick, from waiting on an idle tab forever).
|
|
67
|
+
class Frames
|
|
68
|
+
MAX_LIFETIME = 30 # seconds
|
|
69
|
+
|
|
70
|
+
def initialize(config, reloader)
|
|
71
|
+
@config = config
|
|
72
|
+
@reloader = reloader || LiveReload.new(config)
|
|
73
|
+
@stopped = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stop
|
|
77
|
+
@stopped = true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def each_frame
|
|
81
|
+
last = @reloader.snapshot # before the first frame, so nothing slips through the gap
|
|
82
|
+
yield "retry: 1000\n\n"
|
|
83
|
+
yield ": connected\n\n"
|
|
84
|
+
started = last_beat = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
until @stopped
|
|
86
|
+
sleep @config.live_reload_interval
|
|
87
|
+
current = @reloader.snapshot
|
|
88
|
+
changed = LiveReload.diff(last, current)
|
|
89
|
+
unless changed.empty?
|
|
90
|
+
VueLive.store.clear if @config.reload? # make sure the next request recompiles
|
|
91
|
+
yield "event: change\ndata: #{JSON.generate(files: changed)}\n\n"
|
|
92
|
+
last = current
|
|
93
|
+
end
|
|
94
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
break if now - started >= MAX_LIFETIME
|
|
96
|
+
|
|
97
|
+
if now - last_beat >= HEARTBEAT
|
|
98
|
+
yield ": ping\n\n"
|
|
99
|
+
last_beat = now
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, IOError
|
|
103
|
+
nil # client went away
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Rack 2 style body: the server pulls frames with #each.
|
|
108
|
+
class Stream < Frames
|
|
109
|
+
def each(&block)
|
|
110
|
+
each_frame(&block)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def close
|
|
114
|
+
stop
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Rack 3 streaming body: the server hands us the connection and we push frames into it.
|
|
119
|
+
# Deliberately has no #each, otherwise servers would buffer it as an ordinary body.
|
|
120
|
+
class StreamingBody < Frames
|
|
121
|
+
def call(io)
|
|
122
|
+
each_frame do |frame|
|
|
123
|
+
io.write(frame)
|
|
124
|
+
io.flush if io.respond_to?(:flush)
|
|
125
|
+
end
|
|
126
|
+
ensure
|
|
127
|
+
begin
|
|
128
|
+
io.close if io.respond_to?(:close)
|
|
129
|
+
rescue IOError, SystemCallError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module VueLive
|
|
6
|
+
# Rack middleware answering requests under the configured prefix (default "/vue"):
|
|
7
|
+
#
|
|
8
|
+
# GET /vue/App.vue -> compiled ES module (also at /vue/App.vue.js)
|
|
9
|
+
# GET /vue/components/x.js -> any other allowed file under the component root, as-is
|
|
10
|
+
# GET /vue/-/vue.esm-browser.js -> a local copy of Vue when the project has one
|
|
11
|
+
# GET /vue/-/reload.js, /vue/-/events -> development live reload (client + SSE stream)
|
|
12
|
+
#
|
|
13
|
+
# Everything else is passed to the next app, so it can sit in front of Sprockets, Propshaft,
|
|
14
|
+
# Webpacker or ActionDispatch::Static without interfering with them.
|
|
15
|
+
#
|
|
16
|
+
# use VueLive::Middleware # uses VueLive.config
|
|
17
|
+
# use VueLive::Middleware, prefix: '/components', source_path: 'lib/vue'
|
|
18
|
+
class Middleware
|
|
19
|
+
MIME = {
|
|
20
|
+
'.vue' => 'text/javascript; charset=utf-8',
|
|
21
|
+
'.js' => 'text/javascript; charset=utf-8',
|
|
22
|
+
'.mjs' => 'text/javascript; charset=utf-8',
|
|
23
|
+
'.css' => 'text/css; charset=utf-8',
|
|
24
|
+
'.json' => 'application/json; charset=utf-8',
|
|
25
|
+
'.svg' => 'image/svg+xml',
|
|
26
|
+
'.png' => 'image/png',
|
|
27
|
+
'.jpg' => 'image/jpeg',
|
|
28
|
+
'.jpeg' => 'image/jpeg',
|
|
29
|
+
'.gif' => 'image/gif',
|
|
30
|
+
'.webp' => 'image/webp',
|
|
31
|
+
'.avif' => 'image/avif',
|
|
32
|
+
'.ico' => 'image/x-icon',
|
|
33
|
+
'.woff' => 'font/woff',
|
|
34
|
+
'.woff2' => 'font/woff2',
|
|
35
|
+
'.ttf' => 'font/ttf',
|
|
36
|
+
'.otf' => 'font/otf'
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :config
|
|
40
|
+
|
|
41
|
+
def initialize(app, options = {})
|
|
42
|
+
@app = app
|
|
43
|
+
options = options.dup
|
|
44
|
+
@config = if (cfg = options.delete(:config))
|
|
45
|
+
cfg
|
|
46
|
+
elsif options.empty?
|
|
47
|
+
VueLive.config
|
|
48
|
+
else
|
|
49
|
+
VueLive.config.dup.apply(options)
|
|
50
|
+
end
|
|
51
|
+
# Share the global store when running on the global config so VueLive.store.clear works.
|
|
52
|
+
@store = @config.equal?(VueLive.config) ? nil : Store.new(@config)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def store
|
|
56
|
+
@store || VueLive.store
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(env)
|
|
60
|
+
path = env['PATH_INFO'].to_s
|
|
61
|
+
prefix = config.normalized_prefix
|
|
62
|
+
return @app.call(env) unless path.start_with?("#{prefix}/")
|
|
63
|
+
return method_not_allowed unless %w[GET HEAD].include?(env['REQUEST_METHOD'])
|
|
64
|
+
|
|
65
|
+
rest = path[(prefix.length + 1)..]
|
|
66
|
+
return serve_internal(env, rest) if rest.start_with?('-/')
|
|
67
|
+
|
|
68
|
+
target = store.resolver.resolve(rest)
|
|
69
|
+
return @app.call(env) unless target
|
|
70
|
+
|
|
71
|
+
target.vue? ? serve_component(env, target) : serve_file(env, target)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def serve_component(env, target)
|
|
77
|
+
compiled = store.fetch(target.relative_path)
|
|
78
|
+
return not_modified if fresh?(env, compiled.etag)
|
|
79
|
+
|
|
80
|
+
headers = {
|
|
81
|
+
'content-type' => MIME['.vue'],
|
|
82
|
+
'etag' => compiled.etag,
|
|
83
|
+
'cache-control' => cache_control(env, compiled.digest),
|
|
84
|
+
'x-vue-live' => compiled.backend.to_s
|
|
85
|
+
}
|
|
86
|
+
respond(env, 200, headers, compiled.code)
|
|
87
|
+
rescue CompileError => e
|
|
88
|
+
config.logger.error("[vue_live] #{e.message}")
|
|
89
|
+
if config.production?
|
|
90
|
+
respond(env, 500, { 'content-type' => 'text/plain; charset=utf-8', 'cache-control' => 'no-store' },
|
|
91
|
+
"vue_live: failed to compile #{target.relative_path}\n")
|
|
92
|
+
else
|
|
93
|
+
respond(env, 200, { 'content-type' => MIME['.vue'], 'cache-control' => 'no-store' }, error_module(e))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def serve_file(env, target)
|
|
98
|
+
stat = File.stat(target.absolute_path)
|
|
99
|
+
etag = %("#{Store.file_etag(stat)}")
|
|
100
|
+
return not_modified if fresh?(env, etag)
|
|
101
|
+
|
|
102
|
+
ext = File.extname(target.relative_path).downcase
|
|
103
|
+
headers = {
|
|
104
|
+
'content-type' => MIME.fetch(ext, 'application/octet-stream'),
|
|
105
|
+
'etag' => etag,
|
|
106
|
+
'last-modified' => stat.mtime.httpdate,
|
|
107
|
+
'cache-control' => cache_control(env, etag.delete('"'))
|
|
108
|
+
}
|
|
109
|
+
respond(env, 200, headers, File.binread(target.absolute_path))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Internal endpoints under <prefix>/-/.
|
|
113
|
+
def serve_internal(env, rest)
|
|
114
|
+
case rest
|
|
115
|
+
when '-/vue.esm-browser.js' then serve_vendor(env)
|
|
116
|
+
when '-/reload.js' then live_reload.enabled? ? live_reload.client_response : @app.call(env)
|
|
117
|
+
when '-/events' then live_reload.enabled? ? live_reload.stream_response(env) : @app.call(env)
|
|
118
|
+
else @app.call(env)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def live_reload
|
|
123
|
+
@live_reload ||= LiveReload.new(config)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# /vue/-/vue.esm-browser.js : serve the project's own copy of Vue when there is one.
|
|
127
|
+
def serve_vendor(env)
|
|
128
|
+
return @app.call(env) unless (file = config.local_vue_file)
|
|
129
|
+
|
|
130
|
+
stat = File.stat(file)
|
|
131
|
+
etag = %("vue-#{stat.size.to_s(16)}-#{stat.mtime.to_i.to_s(16)}")
|
|
132
|
+
return not_modified if fresh?(env, etag)
|
|
133
|
+
|
|
134
|
+
headers = { 'content-type' => MIME['.js'], 'etag' => etag, 'cache-control' => cache_control(env, etag.delete('"')) }
|
|
135
|
+
respond(env, 200, headers, File.binread(file))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Digested URLs (?v=<digest>) are immutable; plain URLs must revalidate.
|
|
139
|
+
def cache_control(env, digest)
|
|
140
|
+
query = env['QUERY_STRING'].to_s
|
|
141
|
+
if query.match?(/(?:\A|&)v=#{Regexp.escape(digest)}(?:&|\z)/) && !config.reload?
|
|
142
|
+
'public, max-age=31536000, immutable'
|
|
143
|
+
else
|
|
144
|
+
'no-cache'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fresh?(env, etag)
|
|
149
|
+
inm = env['HTTP_IF_NONE_MATCH']
|
|
150
|
+
inm && inm.split(',').map(&:strip).include?(etag)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def not_modified
|
|
154
|
+
[304, { 'cache-control' => 'no-cache' }, []]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def method_not_allowed
|
|
158
|
+
[405, { 'content-type' => 'text/plain', 'allow' => 'GET, HEAD' }, ['Method Not Allowed']]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def respond(env, status, headers, body)
|
|
162
|
+
body = body.to_s
|
|
163
|
+
headers['content-length'] = body.bytesize.to_s
|
|
164
|
+
[status, headers, env['REQUEST_METHOD'] == 'HEAD' ? [] : [body]]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# In development a broken component becomes a module that throws a readable error, so the
|
|
168
|
+
# failure shows up in the browser console (and in Vue's error overlay if any) instead of a
|
|
169
|
+
# bare network error.
|
|
170
|
+
def error_module(error)
|
|
171
|
+
message = Emitter.js_string("[vue_live] #{error.message}")
|
|
172
|
+
<<~JS
|
|
173
|
+
const message = #{message}
|
|
174
|
+
console.error(message)
|
|
175
|
+
if (typeof document !== 'undefined') {
|
|
176
|
+
const pre = document.createElement('pre')
|
|
177
|
+
pre.setAttribute('data-vue-live-error', '')
|
|
178
|
+
pre.style.cssText = 'position:fixed;left:0;right:0;bottom:0;margin:0;padding:16px;max-height:50vh;overflow:auto;z-index:2147483647;background:#300;color:#fdd;font:13px/1.4 monospace;white-space:pre-wrap;'
|
|
179
|
+
pre.textContent = message
|
|
180
|
+
document.body ? document.body.appendChild(pre) : document.addEventListener('DOMContentLoaded', () => document.body.appendChild(pre))
|
|
181
|
+
}
|
|
182
|
+
throw new Error(message)
|
|
183
|
+
JS
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# Optional Node.js plumbing for the :node compiler backend.
|
|
8
|
+
#
|
|
9
|
+
# When the webpacker_cli gem is available its conventions are reused (package manager detection,
|
|
10
|
+
# project layout); otherwise a yarn.lock / package-lock.json sniff decides between yarn and npm.
|
|
11
|
+
module NodeTools
|
|
12
|
+
PACKAGES = ['@vue/compiler-sfc', 'vue'].freeze
|
|
13
|
+
# Node.js versions from here on strip TypeScript types themselves (node:module#stripTypeScriptTypes).
|
|
14
|
+
TYPE_STRIPPING_NODE = [22, 13].freeze
|
|
15
|
+
# npm packages compile.js can strip TypeScript with, when Node cannot.
|
|
16
|
+
TS_TRANSPILERS = %w[sucrase esbuild typescript @babel/core].freeze
|
|
17
|
+
DEFAULT_TS_TRANSPILER = 'sucrase'
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def webpacker_cli?
|
|
22
|
+
return true if defined?(::WebpackerCli)
|
|
23
|
+
|
|
24
|
+
require 'webpacker_cli'
|
|
25
|
+
true
|
|
26
|
+
rescue LoadError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def node_version(config = VueLive.config)
|
|
31
|
+
out, status = Open3.capture2e(config.node_bin, '--version')
|
|
32
|
+
status.success? ? out.strip : nil
|
|
33
|
+
rescue Errno::ENOENT
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# true when the installed Node strips TypeScript on its own (>= 22.13).
|
|
38
|
+
def node_strips_types?(config = VueLive.config)
|
|
39
|
+
version = node_version(config)
|
|
40
|
+
return false unless version
|
|
41
|
+
|
|
42
|
+
major, minor = version.delete_prefix('v').split('.').map(&:to_i)
|
|
43
|
+
major > TYPE_STRIPPING_NODE[0] || (major == TYPE_STRIPPING_NODE[0] && minor >= TYPE_STRIPPING_NODE[1])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The TypeScript transpiler packages installed under +root+.
|
|
47
|
+
def installed_ts_transpilers(root = VueLive.config.root)
|
|
48
|
+
TS_TRANSPILERS.select { |pkg| File.directory?(File.join(root, 'node_modules', *pkg.split('/'))) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def package_manager(root = VueLive.config.root)
|
|
52
|
+
return ::WebpackerCli.package_manager(root) if webpacker_cli?
|
|
53
|
+
return 'npm' if File.exist?(File.join(root, 'package-lock.json')) && !File.exist?(File.join(root, 'yarn.lock'))
|
|
54
|
+
return 'yarn' if File.exist?(File.join(root, 'yarn.lock')) || which('yarn')
|
|
55
|
+
|
|
56
|
+
'npm'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compiler_sfc_version(root = VueLive.config.root)
|
|
60
|
+
JSON.parse(File.read(File.join(root, 'node_modules', '@vue', 'compiler-sfc', 'package.json')))['version']
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def vue_version(root = VueLive.config.root)
|
|
66
|
+
JSON.parse(File.read(File.join(root, 'node_modules', 'vue', 'package.json')))['version']
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Install @vue/compiler-sfc (and vue, for a local copy of the browser build) into the project.
|
|
72
|
+
def setup(root = VueLive.config.root, packages: PACKAGES, dev: true)
|
|
73
|
+
raise Error, 'Node.js was not found in PATH; install it to use the :node compiler' unless node_version
|
|
74
|
+
|
|
75
|
+
pkg_json = File.join(root, 'package.json')
|
|
76
|
+
File.write(pkg_json, JSON.pretty_generate('name' => File.basename(root), 'private' => true)) unless File.exist?(pkg_json)
|
|
77
|
+
manager = package_manager(root)
|
|
78
|
+
cmd = if manager == 'yarn'
|
|
79
|
+
['yarn', 'add', *(dev ? ['--dev'] : []), *packages]
|
|
80
|
+
else
|
|
81
|
+
['npm', 'install', *(dev ? ['--save-dev'] : []), *packages]
|
|
82
|
+
end
|
|
83
|
+
puts " run #{cmd.join(' ')}"
|
|
84
|
+
success = system(*cmd, chdir: root)
|
|
85
|
+
raise Error, "#{manager} failed to install #{packages.join(', ')}" unless success
|
|
86
|
+
|
|
87
|
+
Compiler::Node.reset!
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Human-readable status for `vue-live check`.
|
|
92
|
+
def problems(config = VueLive.config)
|
|
93
|
+
problems = []
|
|
94
|
+
problems << "component directory does not exist: #{config.source_dir}" unless File.directory?(config.source_dir)
|
|
95
|
+
if %i[node].include?(config.compiler.to_sym)
|
|
96
|
+
problems << 'Node.js was not found in PATH (required by compiler: node)' unless node_version(config)
|
|
97
|
+
problems << '@vue/compiler-sfc is not installed (run `vue-live node-setup`)' unless compiler_sfc_version(config.root)
|
|
98
|
+
end
|
|
99
|
+
if %i[node auto].include?(config.compiler.to_sym) && node_version(config) && !node_strips_types?(config) &&
|
|
100
|
+
installed_ts_transpilers(config.root).empty?
|
|
101
|
+
problems << "Node.js #{node_version(config)} cannot strip TypeScript and no transpiler is installed; " \
|
|
102
|
+
'<script lang="ts"> components will fail (run `vue-live node-setup --with sucrase`)'
|
|
103
|
+
end
|
|
104
|
+
problems
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def which(cmd)
|
|
108
|
+
ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |p|
|
|
109
|
+
f = File.join(p, cmd)
|
|
110
|
+
File.executable?(f) && !File.directory?(f)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# Writes every component as a static `.vue.js` module (plus sibling assets and a manifest) so
|
|
8
|
+
# a CDN or plain file server can serve them. Optional: the middleware is the normal path.
|
|
9
|
+
#
|
|
10
|
+
# VueLive::Precompiler.new.run #=> public/vue/App.vue.js, public/vue/manifest.json
|
|
11
|
+
#
|
|
12
|
+
# The manifest maps "App.vue" => "/vue/App.vue.js?v=<digest>" and is used by vue_live_path when
|
|
13
|
+
# config.use_manifest? is true.
|
|
14
|
+
class Precompiler
|
|
15
|
+
attr_reader :config, :store, :errors
|
|
16
|
+
|
|
17
|
+
def initialize(config = VueLive.config, store: nil)
|
|
18
|
+
@config = config
|
|
19
|
+
@store = store || Store.new(config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Compiles everything it can. Components that fail are reported (and collected in #errors);
|
|
23
|
+
# pass strict: true to raise on the first failure instead.
|
|
24
|
+
def run(output: config.precompile_dir, quiet: false, strict: false, out: $stdout)
|
|
25
|
+
FileUtils.mkdir_p(output)
|
|
26
|
+
manifest = {}
|
|
27
|
+
@errors = errors = []
|
|
28
|
+
prefix = config.normalized_prefix
|
|
29
|
+
|
|
30
|
+
store.resolver.files.each do |rel|
|
|
31
|
+
src = File.join(store.resolver.source_dir, rel)
|
|
32
|
+
if rel.end_with?('.vue')
|
|
33
|
+
begin
|
|
34
|
+
compiled = store.fetch(rel)
|
|
35
|
+
rescue Error => e
|
|
36
|
+
errors << e
|
|
37
|
+
out.puts " FAIL #{rel}: #{e.message}" unless quiet
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
dest = File.join(output, "#{rel}.js")
|
|
41
|
+
write(dest, compiled.code)
|
|
42
|
+
manifest[rel] = "#{prefix}/#{rel}.js?v=#{compiled.digest}"
|
|
43
|
+
else
|
|
44
|
+
dest = File.join(output, rel)
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
46
|
+
FileUtils.cp(src, dest)
|
|
47
|
+
manifest[rel] = "#{prefix}/#{rel}"
|
|
48
|
+
end
|
|
49
|
+
out.puts " write #{dest}" unless quiet
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if (vue = config.local_vue_file)
|
|
53
|
+
dest = File.join(output, '-', 'vue.esm-browser.js')
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
55
|
+
FileUtils.cp(vue, dest)
|
|
56
|
+
out.puts " write #{dest}" unless quiet
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
write(File.join(output, 'manifest.json'), JSON.pretty_generate(manifest))
|
|
60
|
+
raise errors.first if strict && errors.any?
|
|
61
|
+
|
|
62
|
+
manifest
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def clobber(output: config.precompile_dir)
|
|
66
|
+
FileUtils.rm_rf(output)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.manifest(config = VueLive.config)
|
|
70
|
+
@manifests ||= {}
|
|
71
|
+
path = config.manifest_path
|
|
72
|
+
return @manifests[path] if @manifests[path] && !config.reload?
|
|
73
|
+
|
|
74
|
+
@manifests[path] = File.file?(path) ? JSON.parse(File.read(path)) : {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.reset!
|
|
78
|
+
@manifests = {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def write(dest, content)
|
|
84
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
85
|
+
File.write(dest, content)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VueLive
|
|
4
|
+
module Rails
|
|
5
|
+
# ActionView flavour of VueLive::Helpers: html_safe output, automatic CSP nonces and
|
|
6
|
+
# cooperation with importmap-rails (browsers honour only one import map per page).
|
|
7
|
+
module Helper
|
|
8
|
+
include VueLive::Helpers
|
|
9
|
+
|
|
10
|
+
def vue_live_import_map_tag(imports: {}, nonce: nil)
|
|
11
|
+
super(imports: imports, nonce: nonce || vue_live_csp_nonce).html_safe
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def vue_live_mount_tag(component, selector = '#app', nonce: nil, **options)
|
|
15
|
+
super(component, selector, nonce: nonce || vue_live_csp_nonce, **options).html_safe
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def vue_live_module_tag(javascript, nonce: nil)
|
|
19
|
+
super(javascript, nonce: nonce || vue_live_csp_nonce).html_safe
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def vue_live_tags(component, selector = '#app', **options)
|
|
23
|
+
super.html_safe
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def vue_live_reload_tag(nonce: nil)
|
|
27
|
+
super(nonce: nonce || vue_live_csp_nonce).html_safe
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def vue_live_csp_nonce
|
|
33
|
+
respond_to?(:content_security_policy_nonce, true) ? content_security_policy_nonce : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# importmap-rails owns the page's import map; the Railtie pins `vue` into it instead.
|
|
37
|
+
# Set config.vue_live.importmap_pin = false to keep the two separate.
|
|
38
|
+
def vue_live_external_importmap?
|
|
39
|
+
defined?(::Importmap::Map) && VueLive.config.importmap_pin ? true : false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
require_relative '../vue_live'
|
|
5
|
+
require_relative 'rails/helper'
|
|
6
|
+
|
|
7
|
+
module VueLive
|
|
8
|
+
# Wires vue_live into a Rails application with no configuration:
|
|
9
|
+
#
|
|
10
|
+
# * `config.vue_live` exposes every Configuration option (config.vue_live.prefix = '/components')
|
|
11
|
+
# * config/vue_live.yml is read when present (`rails g vue_live:install` writes one)
|
|
12
|
+
# * the middleware is mounted ahead of the asset pipeline's static file server, answering
|
|
13
|
+
# only `/vue/...`, so Sprockets, Propshaft, Webpacker and jsbundling keep their own paths
|
|
14
|
+
# * view helpers are added to ActionView
|
|
15
|
+
# * when importmap-rails is present, `vue` is pinned into the app's import map
|
|
16
|
+
# * rake tasks: vue_live:precompile, vue_live:clobber, vue_live:check, vue_live:node_setup
|
|
17
|
+
class Railtie < ::Rails::Railtie
|
|
18
|
+
config.vue_live = ActiveSupport::OrderedOptions.new
|
|
19
|
+
|
|
20
|
+
# Make `Rails.root`, `Rails.env` and `Rails.logger` the source of truth before anything reads them.
|
|
21
|
+
initializer 'vue_live.configure', before: :load_environment_config do |app|
|
|
22
|
+
VueLive.config.root = app.root.to_s
|
|
23
|
+
VueLive.config.env = ::Rails.env.to_s
|
|
24
|
+
VueLive.config.logger = ::Rails.logger if ::Rails.logger
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer 'vue_live.apply_configuration', after: :load_environment_config do |app|
|
|
28
|
+
VueLive.config.root = app.root.to_s
|
|
29
|
+
VueLive.config.env = ::Rails.env.to_s
|
|
30
|
+
VueLive.load_config_file
|
|
31
|
+
VueLive.config.apply(app.config.vue_live.to_h)
|
|
32
|
+
VueLive.reset_store!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
initializer 'vue_live.middleware' do |app|
|
|
36
|
+
app.config.after_initialize do
|
|
37
|
+
VueLive.config.logger ||= ::Rails.logger
|
|
38
|
+
end
|
|
39
|
+
next unless app.config.vue_live.fetch(:middleware, true)
|
|
40
|
+
|
|
41
|
+
if defined?(::ActionDispatch::Static) && app.middleware.respond_to?(:insert_before)
|
|
42
|
+
begin
|
|
43
|
+
app.middleware.insert_before ::ActionDispatch::Static, VueLive::Middleware
|
|
44
|
+
rescue StandardError
|
|
45
|
+
app.middleware.use VueLive::Middleware
|
|
46
|
+
end
|
|
47
|
+
else
|
|
48
|
+
app.middleware.use VueLive::Middleware
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
initializer 'vue_live.helpers' do
|
|
53
|
+
ActiveSupport.on_load(:action_view) { include VueLive::Rails::Helper }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
initializer 'vue_live.importmap', after: 'importmap' do |app|
|
|
57
|
+
next unless defined?(::Importmap) && app.respond_to?(:importmap)
|
|
58
|
+
|
|
59
|
+
app.config.after_initialize do
|
|
60
|
+
next unless VueLive.config.importmap_pin
|
|
61
|
+
|
|
62
|
+
map = app.importmap
|
|
63
|
+
next if map.packages.key?('vue')
|
|
64
|
+
|
|
65
|
+
map.draw { pin 'vue', to: VueLive.config.resolved_vue_url, preload: true }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
rake_tasks do
|
|
70
|
+
load File.expand_path('tasks.rake', __dir__)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
generators do
|
|
74
|
+
require_relative '../generators/vue_live/install/install_generator'
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|