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.
@@ -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