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,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../scoped_css'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
module Compiler
|
|
8
|
+
# The zero-dependency backend.
|
|
9
|
+
#
|
|
10
|
+
# Output for a component with <template>, <script> and <style scoped>:
|
|
11
|
+
#
|
|
12
|
+
# import Something from './Something.vue' // the user's own script, untouched
|
|
13
|
+
# const __sfc__ = { components: { Something }, data() { ... } }
|
|
14
|
+
# __sfc__.template = "<div class=\"a\">…</div>"
|
|
15
|
+
# __sfc__.__scopeId = "data-v-1a2b3c4d" // Vue 3 applies this in the renderer
|
|
16
|
+
# __sfc__._scopeId = "data-v-1a2b3c4d" // same for Vue 2
|
|
17
|
+
# export default __sfc__
|
|
18
|
+
#
|
|
19
|
+
# The template string is compiled in the browser by Vue's *full* build, which is what the
|
|
20
|
+
# old Sprockets .vue setups relied on too. Scope ids are applied by Vue's renderer, not its
|
|
21
|
+
# template compiler, so scoped styles work without a build step.
|
|
22
|
+
class Ruby < Base
|
|
23
|
+
EXPORT_DEFAULT = /^[ \t]*export[ \t]+default[ \t]+/m
|
|
24
|
+
MODULE_EXPORTS = /^[ \t]*module\.exports[ \t]*=[ \t]*/m
|
|
25
|
+
|
|
26
|
+
def compile(descriptor, relative_path:, absolute_path: nil)
|
|
27
|
+
unless descriptor.advanced_features.empty?
|
|
28
|
+
raise UnsupportedFeature.new(
|
|
29
|
+
"uses #{descriptor.advanced_features.join(', ')}; the Ruby compiler supports plain " \
|
|
30
|
+
'<template>, <script> and <style> blocks only (switch to the :node compiler for more)',
|
|
31
|
+
file: relative_path
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
dependencies = []
|
|
36
|
+
scope_id = Compiler.scope_id(relative_path)
|
|
37
|
+
scoped = descriptor.scoped_styles?
|
|
38
|
+
|
|
39
|
+
script, script_file = block_source(descriptor.script, absolute_path, dependencies)
|
|
40
|
+
code = rewrite_script(script.to_s, relative_path)
|
|
41
|
+
map = build_map(descriptor, script.to_s, code, script_file.nil?) if config.source_maps?
|
|
42
|
+
|
|
43
|
+
if descriptor.template
|
|
44
|
+
template, = block_source(descriptor.template, absolute_path, dependencies)
|
|
45
|
+
code << "__sfc__.template = #{js_string(template.strip)}\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if scoped
|
|
49
|
+
code << "__sfc__.__scopeId = #{js_string(scope_id)}\n"
|
|
50
|
+
code << "__sfc__._scopeId = #{js_string(scope_id)}\n"
|
|
51
|
+
end
|
|
52
|
+
code << "__sfc__.__file = #{js_string(relative_path)}\n" unless config.production?
|
|
53
|
+
code << "export default __sfc__\n"
|
|
54
|
+
|
|
55
|
+
css = descriptor.styles.map do |style|
|
|
56
|
+
content, = block_source(style, absolute_path, dependencies)
|
|
57
|
+
style.scoped? ? ScopedCSS.rewrite(content, scope_id) : content
|
|
58
|
+
end.join("\n")
|
|
59
|
+
|
|
60
|
+
Result.new(code: code, css: css, source_map: map&.to_h, scope_id: scoped ? scope_id : nil, backend: :ruby,
|
|
61
|
+
dependencies: dependencies)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# `export default {...}` becomes `const __sfc__ = {...}` so we can decorate the options
|
|
67
|
+
# object before re-exporting it. Works for `export default defineComponent({...})` too.
|
|
68
|
+
def rewrite_script(script, relative_path)
|
|
69
|
+
body = script.dup
|
|
70
|
+
if body.match?(EXPORT_DEFAULT)
|
|
71
|
+
body.sub!(EXPORT_DEFAULT, 'const __sfc__ = ')
|
|
72
|
+
elsif body.match?(MODULE_EXPORTS)
|
|
73
|
+
body.sub!(MODULE_EXPORTS, 'const __sfc__ = ')
|
|
74
|
+
else
|
|
75
|
+
body << "\nconst __sfc__ = {}"
|
|
76
|
+
end
|
|
77
|
+
if body.match?(EXPORT_DEFAULT)
|
|
78
|
+
raise CompileError.new('only one `export default` is allowed in <script>', file: relative_path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
"#{body.rstrip}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The script block is copied line for line (only `export default` is rewritten in place), so
|
|
85
|
+
# each generated line of it maps straight to its line in the .vue file.
|
|
86
|
+
def build_map(descriptor, script, code, inline_script)
|
|
87
|
+
map = SourceMap.new(descriptor.filename, descriptor.source)
|
|
88
|
+
return map unless descriptor.script && inline_script && !script.empty?
|
|
89
|
+
|
|
90
|
+
# Block content starts right after the opening tag, so its first line is the tag's line.
|
|
91
|
+
script_lines = script.rstrip.split("\n", -1).length # the trailing newline is not a line
|
|
92
|
+
generated_lines = code.split("\n", -1).length
|
|
93
|
+
map.add_block(generated_line: 1, original_line: descriptor.script.line, count: [script_lines, generated_lines].min)
|
|
94
|
+
map
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def js_string(str)
|
|
98
|
+
# JSON is a subset of JS string literal syntax; escape the two JS-only line terminators and
|
|
99
|
+
# "</script>" so the output is safe even when inlined into HTML.
|
|
100
|
+
JSON.generate(str).gsub('
', '
').gsub('
', '
').gsub('</', '<\/')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'sfc/parser'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# Turns a .vue source into the pieces of an ES module. Two backends implement the same
|
|
8
|
+
# interface:
|
|
9
|
+
#
|
|
10
|
+
# VueLive::Compiler::Ruby - pure Ruby, no dependencies. Templates stay as strings and are
|
|
11
|
+
# compiled in the browser by Vue's full build.
|
|
12
|
+
# VueLive::Compiler::Node - @vue/compiler-sfc via Node.js. Templates become render
|
|
13
|
+
# functions; <script setup>, TypeScript and preprocessors work.
|
|
14
|
+
module Compiler
|
|
15
|
+
# What a backend hands back. +code+ is the module body *without* style injection and
|
|
16
|
+
# before import rewriting; Emitter finishes the job.
|
|
17
|
+
# +source_map+ is an optional source map (Hash, v3) whose generated lines refer to +code+.
|
|
18
|
+
Result = Struct.new(:code, :css, :source_map, :scope_id, :backend, :dependencies, keyword_init: true) do
|
|
19
|
+
def css?
|
|
20
|
+
css && !css.strip.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deterministic scope id for a component, derived from its path relative to the component
|
|
25
|
+
# root so it is stable across machines and deploys: "data-v-1a2b3c4d".
|
|
26
|
+
def self.scope_id(relative_path)
|
|
27
|
+
"data-v-#{Digest::SHA256.hexdigest(relative_path.to_s.tr('\\', '/'))[0, 8]}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Pick a backend for +descriptor+ according to the configured strategy.
|
|
31
|
+
def self.for(descriptor, config = VueLive.config)
|
|
32
|
+
case config.compiler.to_s
|
|
33
|
+
when 'ruby' then Ruby.new(config)
|
|
34
|
+
when 'node' then Node.new(config)
|
|
35
|
+
when 'auto', ''
|
|
36
|
+
if descriptor.advanced_features.empty?
|
|
37
|
+
Ruby.new(config)
|
|
38
|
+
elsif Node.available?(config)
|
|
39
|
+
Node.new(config)
|
|
40
|
+
else
|
|
41
|
+
raise UnsupportedFeature.new(
|
|
42
|
+
"uses #{descriptor.advanced_features.join(', ')}, which the pure Ruby compiler cannot handle. " \
|
|
43
|
+
'Install Node.js and @vue/compiler-sfc (`vue-live node-setup`) to enable the Node backend, ' \
|
|
44
|
+
'or rewrite the component with a plain <script> / <style> block.',
|
|
45
|
+
file: descriptor.filename
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
raise Error, "unknown compiler #{config.compiler.inspect} (expected :ruby, :node or :auto)"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse + compile in one go. +relative_path+ is the component's path under the source dir
|
|
54
|
+
# and +absolute_path+ (optional) lets `src="..."` attributes resolve sibling files.
|
|
55
|
+
def self.compile(source, relative_path:, absolute_path: nil, config: VueLive.config)
|
|
56
|
+
descriptor = SFC::Parser.parse(source, filename: relative_path)
|
|
57
|
+
backend = self.for(descriptor, config)
|
|
58
|
+
backend.compile(descriptor, relative_path: relative_path, absolute_path: absolute_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Base
|
|
62
|
+
attr_reader :config
|
|
63
|
+
|
|
64
|
+
def initialize(config = VueLive.config)
|
|
65
|
+
@config = config
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def name
|
|
69
|
+
self.class.name.split('::').last.downcase.to_sym
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve a block's `src="./file"` attribute against the component's own location.
|
|
73
|
+
# Returns [content, path] or [block.content, nil].
|
|
74
|
+
def block_source(block, absolute_path, dependencies)
|
|
75
|
+
return [block&.content, nil] unless block&.src
|
|
76
|
+
raise CompileError.new('src="..." attributes need the component path', file: absolute_path) unless absolute_path
|
|
77
|
+
|
|
78
|
+
path = File.expand_path(block.src, File.dirname(absolute_path))
|
|
79
|
+
root = File.expand_path(config.source_dir)
|
|
80
|
+
unless path.start_with?(root + File::SEPARATOR)
|
|
81
|
+
raise ForbiddenPath, "#{block.src} resolves outside the component root"
|
|
82
|
+
end
|
|
83
|
+
raise CompileError.new("src file not found: #{block.src}", file: absolute_path) unless File.file?(path)
|
|
84
|
+
|
|
85
|
+
dependencies << path
|
|
86
|
+
[File.read(path), path]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
require_relative 'compiler/ruby'
|
|
93
|
+
require_relative 'compiler/node'
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'logger'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# All tunables live here. Values come from (lowest to highest precedence):
|
|
8
|
+
#
|
|
9
|
+
# 1. built-in defaults
|
|
10
|
+
# 2. config/vue_live.yml (``default`` section merged with the current environment section)
|
|
11
|
+
# 3. Ruby: VueLive.configure { |c| ... } / Rails: config.vue_live.xxx = ...
|
|
12
|
+
#
|
|
13
|
+
# Everything is plain Ruby so it works the same under Rails, Sinatra or bare Rack.
|
|
14
|
+
class Configuration
|
|
15
|
+
VUE_VERSION = '3.5.43'
|
|
16
|
+
CDN_TEMPLATE = 'https://cdn.jsdelivr.net/npm/vue@%<version>s/dist/%<file>s'
|
|
17
|
+
|
|
18
|
+
# Files the middleware is willing to serve from the component root. Everything else is 404.
|
|
19
|
+
DEFAULT_EXTENSIONS = %w[.vue .js .mjs .css .json .svg .png .jpg .jpeg .gif .webp .avif .ico .woff .woff2 .ttf .otf].freeze
|
|
20
|
+
|
|
21
|
+
# Where the project lives. Rails.root under Rails, the current directory elsewhere.
|
|
22
|
+
attr_writer :root
|
|
23
|
+
# Directory holding the .vue components, relative to +root+ (or absolute).
|
|
24
|
+
attr_accessor :source_path
|
|
25
|
+
# URL prefix the middleware answers on.
|
|
26
|
+
attr_accessor :prefix
|
|
27
|
+
# Current environment name: development / test / production.
|
|
28
|
+
attr_writer :env
|
|
29
|
+
# :ruby (pure Ruby, zero dependencies), :node (@vue/compiler-sfc through Node.js) or :auto
|
|
30
|
+
# (Ruby, falling back to Node only for components the Ruby backend cannot handle).
|
|
31
|
+
attr_accessor :compiler
|
|
32
|
+
# Re-check mtimes and recompile changed files on every request. On in development and test.
|
|
33
|
+
attr_accessor :reload
|
|
34
|
+
# :memory keeps compiled modules in the process, :file additionally persists them under
|
|
35
|
+
# +cache_path+ (survives restarts, shared between workers), :none disables caching.
|
|
36
|
+
attr_accessor :cache
|
|
37
|
+
attr_accessor :cache_path, :vue_version
|
|
38
|
+
attr_writer :logger
|
|
39
|
+
# URL of the Vue ESM build. nil = auto: a local copy under node_modules or vendor/vue_live when
|
|
40
|
+
# present, otherwise the pinned jsDelivr build. Must be a *full* build (with the template
|
|
41
|
+
# compiler) when components ship their templates as strings, i.e. with the Ruby backend.
|
|
42
|
+
attr_accessor :vue_url
|
|
43
|
+
# Extra entries for the generated import map, e.g. { 'pinia' => 'https://...' }.
|
|
44
|
+
attr_accessor :import_map
|
|
45
|
+
# Node.js executable used by the :node compiler.
|
|
46
|
+
attr_accessor :node_bin
|
|
47
|
+
# Allowed file extensions under +source_path+.
|
|
48
|
+
attr_accessor :extensions
|
|
49
|
+
# Output directory for +vue-live compile+ (precompiled, static ES modules).
|
|
50
|
+
attr_accessor :precompile_path
|
|
51
|
+
# Use public/vue/manifest.json for URLs instead of the live middleware. nil = auto
|
|
52
|
+
# (true in production when the manifest exists).
|
|
53
|
+
attr_accessor :use_manifest
|
|
54
|
+
# Mount the middleware automatically (Railtie / Sinatra extension). Set false to +use+ it yourself.
|
|
55
|
+
attr_accessor :middleware
|
|
56
|
+
# Register a `vue` pin with importmap-rails automatically when that gem is present.
|
|
57
|
+
attr_accessor :importmap_pin
|
|
58
|
+
# Hook `vue_live:precompile` into `assets:precompile` under Rails (off by default: live is the point).
|
|
59
|
+
attr_accessor :hook_assets_precompile
|
|
60
|
+
# Whether to emit `<style>` content into the module (true) or drop styles entirely (false).
|
|
61
|
+
attr_accessor :inject_styles
|
|
62
|
+
# Append an inline source map to every compiled module. nil = on outside production.
|
|
63
|
+
attr_accessor :source_maps
|
|
64
|
+
# Keep one long-lived `node compile.js --server` process per configuration (true) or spawn a
|
|
65
|
+
# process per compile (false).
|
|
66
|
+
attr_accessor :node_worker
|
|
67
|
+
# Development live reload: SSE stream at <prefix>/-/events + client at <prefix>/-/reload.js.
|
|
68
|
+
# nil = on whenever +reload+ is on.
|
|
69
|
+
attr_accessor :live_reload
|
|
70
|
+
# Seconds between directory scans for live reload.
|
|
71
|
+
attr_accessor :live_reload_interval
|
|
72
|
+
# Add `?v=<digest>` to the relative imports inside compiled modules so every file a page loads
|
|
73
|
+
# can be cached immutably.
|
|
74
|
+
attr_accessor :digest_imports
|
|
75
|
+
|
|
76
|
+
def initialize
|
|
77
|
+
@root = nil
|
|
78
|
+
@source_path = 'app/vue'
|
|
79
|
+
@prefix = '/vue'
|
|
80
|
+
@env = nil
|
|
81
|
+
@compiler = :auto
|
|
82
|
+
@reload = nil
|
|
83
|
+
@cache = :memory
|
|
84
|
+
@cache_path = 'tmp/cache/vue_live'
|
|
85
|
+
@vue_url = nil
|
|
86
|
+
@vue_version = VUE_VERSION
|
|
87
|
+
@import_map = {}
|
|
88
|
+
@node_bin = ENV['VUE_LIVE_NODE'] || 'node'
|
|
89
|
+
@extensions = DEFAULT_EXTENSIONS.dup
|
|
90
|
+
@precompile_path = 'public/vue'
|
|
91
|
+
@use_manifest = nil
|
|
92
|
+
@middleware = true
|
|
93
|
+
@logger = nil
|
|
94
|
+
@importmap_pin = true
|
|
95
|
+
@hook_assets_precompile = false
|
|
96
|
+
@inject_styles = true
|
|
97
|
+
@source_maps = nil
|
|
98
|
+
@node_worker = true
|
|
99
|
+
@live_reload = nil
|
|
100
|
+
@live_reload_interval = 0.5
|
|
101
|
+
@digest_imports = true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def root
|
|
105
|
+
@root ||= VueLive.detect_root
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def env
|
|
109
|
+
@env ||= VueLive.detect_env
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def production?
|
|
113
|
+
env.to_s == 'production'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def reload?
|
|
117
|
+
@reload.nil? ? !production? : !!@reload
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def source_maps?
|
|
121
|
+
@source_maps.nil? ? !production? : !!@source_maps
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def live_reload?
|
|
125
|
+
@live_reload.nil? ? reload? : !!@live_reload
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def logger
|
|
129
|
+
@logger ||= default_logger
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def source_dir
|
|
133
|
+
File.expand_path(source_path.to_s, root)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def cache_dir
|
|
137
|
+
File.expand_path(cache_path.to_s, root)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def precompile_dir
|
|
141
|
+
File.expand_path(precompile_path.to_s, root)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def manifest_path
|
|
145
|
+
File.join(precompile_dir, 'manifest.json')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def use_manifest?
|
|
149
|
+
return !!@use_manifest unless @use_manifest.nil?
|
|
150
|
+
|
|
151
|
+
production? && File.exist?(manifest_path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalised prefix: leading slash, no trailing slash ("/vue").
|
|
155
|
+
def normalized_prefix
|
|
156
|
+
p = "/#{prefix.to_s.gsub(%r{\A/+|/+\z}, '')}"
|
|
157
|
+
p == '/' ? '' : p
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The URL the browser loads Vue itself from.
|
|
161
|
+
def resolved_vue_url
|
|
162
|
+
return vue_url if vue_url && !vue_url.empty?
|
|
163
|
+
return "#{normalized_prefix}/-/vue.esm-browser.js" if local_vue_file
|
|
164
|
+
|
|
165
|
+
format(CDN_TEMPLATE, version: vue_version, file: production? ? 'vue.esm-browser.prod.js' : 'vue.esm-browser.js')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A copy of Vue's full ESM browser build inside the project, if one is installed.
|
|
169
|
+
def local_vue_file
|
|
170
|
+
candidates = [
|
|
171
|
+
File.join(root, 'vendor', 'vue_live', production? ? 'vue.esm-browser.prod.js' : 'vue.esm-browser.js'),
|
|
172
|
+
File.join(root, 'vendor', 'vue_live', 'vue.esm-browser.js'),
|
|
173
|
+
File.join(root, 'node_modules', 'vue', 'dist', production? ? 'vue.esm-browser.prod.js' : 'vue.esm-browser.js'),
|
|
174
|
+
File.join(root, 'node_modules', 'vue', 'dist', 'vue.esm-browser.js')
|
|
175
|
+
]
|
|
176
|
+
candidates.find { |f| File.file?(f) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Merge a config/vue_live.yml (``default`` + env sections) into this object.
|
|
180
|
+
def load_yaml(file = File.join(root, 'config', 'vue_live.yml'))
|
|
181
|
+
return self unless File.file?(file)
|
|
182
|
+
|
|
183
|
+
data = YAML.safe_load_file(file, aliases: true, symbolize_names: false) || {}
|
|
184
|
+
merged = (data['default'] || {}).merge(data[env.to_s] || {})
|
|
185
|
+
apply(merged)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Apply a Hash of settings (string or symbol keys).
|
|
189
|
+
def apply(hash)
|
|
190
|
+
hash.each do |key, value|
|
|
191
|
+
setter = "#{key}="
|
|
192
|
+
next unless respond_to?(setter)
|
|
193
|
+
|
|
194
|
+
value = value.to_sym if %w[compiler cache].include?(key.to_s) && value.is_a?(String)
|
|
195
|
+
public_send(setter, value)
|
|
196
|
+
end
|
|
197
|
+
self
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def to_h
|
|
201
|
+
instance_variables.each_with_object({}) do |ivar, h|
|
|
202
|
+
name = ivar.to_s.delete('@')
|
|
203
|
+
next if name == 'logger'
|
|
204
|
+
|
|
205
|
+
h[name] = instance_variable_get(ivar)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def default_logger
|
|
212
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
213
|
+
::Rails.logger
|
|
214
|
+
else
|
|
215
|
+
Logger.new($stderr).tap { |l| l.level = production? ? Logger::INFO : Logger::DEBUG }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# Finishes a Compiler::Result into the JavaScript the browser receives:
|
|
8
|
+
#
|
|
9
|
+
# * relative imports of `./Foo.vue` become `./Foo.vue.js` so the same output works from the
|
|
10
|
+
# live middleware and from a precompiled directory served by any static file server
|
|
11
|
+
# (a block can further rewrite each relative specifier, e.g. to add `?v=<digest>`)
|
|
12
|
+
# * <style> content is injected once per component via a constructable stylesheet
|
|
13
|
+
# (CSP-friendly), falling back to a <style> element
|
|
14
|
+
# * the source map, when present, is appended inline
|
|
15
|
+
#
|
|
16
|
+
# The component code always starts on line 1 so source maps need no offset.
|
|
17
|
+
module Emitter
|
|
18
|
+
# Any relative/absolute-path import specifier:
|
|
19
|
+
# import X from './x.vue' import './x.js' export * from '../y.vue' import('./z.vue')
|
|
20
|
+
RELATIVE_IMPORT = %r{((?:\bfrom\s*|\bimport\s*\(?\s*|\bexport\s+\*\s+from\s*)\s*)(['"])((?:\.{1,2}/|/)[^'"?#]+)([?#][^'"]*)?\2} # rubocop:disable Layout/LineLength
|
|
21
|
+
|
|
22
|
+
STYLE_RUNTIME = <<~JS
|
|
23
|
+
;(function (css, key) {
|
|
24
|
+
if (typeof document === 'undefined') return
|
|
25
|
+
var registry = (globalThis.__vueLiveStyles = globalThis.__vueLiveStyles || {})
|
|
26
|
+
if (registry[key]) return
|
|
27
|
+
registry[key] = true
|
|
28
|
+
try {
|
|
29
|
+
if (document.adoptedStyleSheets && typeof CSSStyleSheet === 'function') {
|
|
30
|
+
var sheet = new CSSStyleSheet()
|
|
31
|
+
sheet.replaceSync(css)
|
|
32
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
} catch (e) { /* fall through to a <style> element */ }
|
|
36
|
+
var el = document.createElement('style')
|
|
37
|
+
el.setAttribute('data-vue-live', key)
|
|
38
|
+
el.textContent = css
|
|
39
|
+
document.head.appendChild(el)
|
|
40
|
+
})(%<css>s, %<key>s)
|
|
41
|
+
JS
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# +rewrite+ receives each relative specifier (already `.vue` -> `.vue.js`) and returns the
|
|
46
|
+
# specifier to emit; nil keeps it.
|
|
47
|
+
def emit(result, relative_path:, inject_styles: true, banner: true, source_map: true, &rewrite)
|
|
48
|
+
out = rewrite_imports(result.code, &rewrite)
|
|
49
|
+
out << "\n" unless out.end_with?("\n")
|
|
50
|
+
out << "// #{relative_path} — compiled by vue_live (#{result.backend})\n" if banner
|
|
51
|
+
if inject_styles && result.css?
|
|
52
|
+
key = "vue-live-#{Digest::SHA256.hexdigest(relative_path)[0, 10]}"
|
|
53
|
+
out << format(STYLE_RUNTIME, css: js_string(result.css), key: js_string(key))
|
|
54
|
+
end
|
|
55
|
+
out << SourceMap.inline_comment(result.source_map) if source_map && result.source_map && !result.source_map.empty?
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def rewrite_imports(code)
|
|
60
|
+
code.gsub(RELATIVE_IMPORT) do
|
|
61
|
+
prefix, quote, spec, suffix = Regexp.last_match[1..4]
|
|
62
|
+
spec = "#{spec}.js" if spec.end_with?('.vue')
|
|
63
|
+
spec = (block_given? && yield(spec)) || spec unless suffix # leave explicit queries alone
|
|
64
|
+
"#{prefix}#{quote}#{spec}#{suffix}#{quote}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def js_string(str)
|
|
69
|
+
JSON.generate(str).gsub('
', '
').gsub('
', '
').gsub('</', '<\/')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VueLive
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a .vue file cannot be turned into a JavaScript module.
|
|
7
|
+
class CompileError < Error
|
|
8
|
+
attr_reader :file
|
|
9
|
+
|
|
10
|
+
def initialize(message, file: nil)
|
|
11
|
+
@file = file
|
|
12
|
+
super(file ? "#{file}: #{message}" : message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when a component uses a feature the selected compiler backend cannot handle
|
|
17
|
+
# (for example <script setup> with the pure Ruby backend).
|
|
18
|
+
class UnsupportedFeature < CompileError; end
|
|
19
|
+
|
|
20
|
+
# Raised when a requested path escapes the component root or has a disallowed extension.
|
|
21
|
+
class ForbiddenPath < Error; end
|
|
22
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module VueLive
|
|
6
|
+
# Framework-agnostic view helpers. They return plain Strings; the Rails module wraps them in
|
|
7
|
+
# html_safe and adds CSP nonces. Include into any view context:
|
|
8
|
+
#
|
|
9
|
+
# helpers VueLive::Helpers # Sinatra
|
|
10
|
+
# include VueLive::Helpers # ERB / Tilt / anything with a binding
|
|
11
|
+
module Helpers
|
|
12
|
+
# URL for a component (or any file under the component root), with a content digest for
|
|
13
|
+
# cache busting: vue_live_path('App.vue') #=> "/vue/App.vue.js?v=1a2b3c4d5e6f7a8b"
|
|
14
|
+
def vue_live_path(component)
|
|
15
|
+
config = vue_live_config
|
|
16
|
+
rel = component.to_s.sub(%r{\A/+}, '')
|
|
17
|
+
if config.use_manifest?
|
|
18
|
+
manifest = VueLive::Precompiler.manifest(config)
|
|
19
|
+
return manifest[rel] || manifest[rel.sub(/\.vue\.js\z/, '.vue')] || "#{config.normalized_prefix}/#{rel}"
|
|
20
|
+
end
|
|
21
|
+
url = "#{config.normalized_prefix}/#{rel.end_with?('.vue') ? "#{rel}.js" : rel}"
|
|
22
|
+
digest = rel.end_with?('.vue') ? VueLive.store.digest(rel) : nil
|
|
23
|
+
digest ? "#{url}?v=#{digest}" : url
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The import map every page needs once, before any module script:
|
|
27
|
+
#
|
|
28
|
+
# <script type="importmap">{"imports":{"vue":"https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.prod.js"}}</script>
|
|
29
|
+
#
|
|
30
|
+
# Extra entries come from config.import_map and the +imports+ argument. Returns an empty
|
|
31
|
+
# string when importmap-rails is managing the page's import map (the Railtie pins `vue` there).
|
|
32
|
+
def vue_live_import_map_tag(imports: {}, nonce: nil)
|
|
33
|
+
return '' if vue_live_external_importmap?
|
|
34
|
+
|
|
35
|
+
map = { 'imports' => vue_live_import_map(imports) }
|
|
36
|
+
%(<script type="importmap"#{vue_live_nonce_attr(nonce)}>#{JSON.generate(map).gsub('</', '<\/')}</script>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def vue_live_import_map(extra = {})
|
|
40
|
+
config = vue_live_config
|
|
41
|
+
{ 'vue' => config.resolved_vue_url }.merge(stringify_keys(config.import_map)).merge(stringify_keys(extra))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# <script type="module"> that imports a component and mounts it on +selector+:
|
|
45
|
+
#
|
|
46
|
+
# vue_live_mount_tag 'App.vue', '#app', props: { user: current_user.name }
|
|
47
|
+
#
|
|
48
|
+
# Pass +create: false+ to get only the import and an exported `app` you can configure
|
|
49
|
+
# yourself, or use vue_live_module_tag for arbitrary code.
|
|
50
|
+
def vue_live_mount_tag(component, selector = '#app', props: {}, nonce: nil, element: false, plugins: [])
|
|
51
|
+
config = vue_live_config
|
|
52
|
+
url = vue_live_path(component)
|
|
53
|
+
js = +''
|
|
54
|
+
if config.vue_version.to_s.start_with?('2')
|
|
55
|
+
js << "import Vue from 'vue'\nimport App from #{js_str(url)}\n"
|
|
56
|
+
js << "new Vue({ render: h => h(App, { props: #{JSON.generate(props)} }) }).$mount(#{js_str(selector)})\n"
|
|
57
|
+
else
|
|
58
|
+
js << "import { createApp } from 'vue'\nimport App from #{js_str(url)}\n"
|
|
59
|
+
js << "const app = createApp(App, #{JSON.generate(props)})\n"
|
|
60
|
+
Array(plugins).each { |plugin| js << "app.use(#{plugin})\n" }
|
|
61
|
+
js << "app.mount(#{js_str(selector)})\n"
|
|
62
|
+
end
|
|
63
|
+
tag = vue_live_module_tag(js, nonce: nonce)
|
|
64
|
+
tag = "#{tag}\n#{vue_live_reload_tag(nonce: nonce)}" if config.live_reload?
|
|
65
|
+
element ? "#{vue_live_mount_element(selector)}\n#{tag}" : tag
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Development live reload client (reloads the page when a component changes). Included by
|
|
69
|
+
# vue_live_mount_tag whenever config.live_reload? is on; renders nothing otherwise.
|
|
70
|
+
def vue_live_reload_tag(nonce: nil)
|
|
71
|
+
config = vue_live_config
|
|
72
|
+
return '' unless config.live_reload?
|
|
73
|
+
|
|
74
|
+
prefix = config.normalized_prefix
|
|
75
|
+
%(<script src="#{prefix}/-/reload.js" data-events="#{prefix}/-/events" defer#{vue_live_nonce_attr(nonce)}></script>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def vue_live_module_tag(javascript, nonce: nil)
|
|
79
|
+
%(<script type="module"#{vue_live_nonce_attr(nonce)}>\n#{javascript.to_s.gsub('</script', '<\/script')}</script>)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Everything a simple page needs: import map + mount script (+ the mount element).
|
|
83
|
+
def vue_live_tags(component, selector = '#app', **options)
|
|
84
|
+
[vue_live_import_map_tag(nonce: options[:nonce]), vue_live_mount_tag(component, selector, **options)].join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def vue_live_config
|
|
90
|
+
VueLive.config
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def vue_live_mount_element(selector)
|
|
94
|
+
if selector.start_with?('#')
|
|
95
|
+
%(<div id="#{selector[1..]}"></div>)
|
|
96
|
+
else
|
|
97
|
+
%(<div class="#{selector.sub(/\A\./, '')}"></div>)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Overridden by the Rails helper to detect importmap-rails.
|
|
102
|
+
def vue_live_external_importmap?
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def vue_live_nonce_attr(nonce)
|
|
107
|
+
nonce ? %( nonce="#{nonce}") : ''
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def js_str(str)
|
|
111
|
+
JSON.generate(str.to_s).gsub('</', '<\/')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def stringify_keys(hash)
|
|
115
|
+
(hash || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|