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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VueLive
4
+ # Maps request paths to files under the component root, refusing anything that escapes it.
5
+ class Resolver
6
+ Target = Struct.new(:relative_path, :absolute_path, :vue?, :digest_hint, keyword_init: true)
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def source_dir
13
+ @config.source_dir
14
+ end
15
+
16
+ # +path+ is the request path *after* the URL prefix, e.g. "components/App.vue.js".
17
+ # Returns a Target or nil when nothing under the root matches.
18
+ def resolve(path)
19
+ rel = clean(path)
20
+ return nil if rel.nil? || rel.empty?
21
+
22
+ rel = rel.sub(/\.vue\.js\z/, '.vue') # "App.vue.js" is the same component as "App.vue"
23
+ ext = File.extname(rel).downcase
24
+ return nil unless @config.extensions.include?(ext)
25
+
26
+ root = File.expand_path(source_dir)
27
+ abs = File.expand_path(rel, root)
28
+ return nil unless abs.start_with?(root + File::SEPARATOR)
29
+ return nil unless File.file?(abs)
30
+
31
+ Target.new(relative_path: rel, absolute_path: abs, vue?: ext == '.vue')
32
+ end
33
+
34
+ # Every .vue component under the root, as relative paths.
35
+ def components
36
+ root = File.expand_path(source_dir)
37
+ return [] unless File.directory?(root)
38
+
39
+ Dir.glob('**/*.vue', base: root).sort
40
+ end
41
+
42
+ # All servable files (components and their sibling assets).
43
+ def files
44
+ root = File.expand_path(source_dir)
45
+ return [] unless File.directory?(root)
46
+
47
+ Dir.glob('**/*', base: root).select do |rel|
48
+ File.file?(File.join(root, rel)) && @config.extensions.include?(File.extname(rel).downcase)
49
+ end.sort
50
+ end
51
+
52
+ private
53
+
54
+ def clean(path)
55
+ return nil if path.nil?
56
+
57
+ decoded = path.to_s.gsub(/%([0-9a-fA-F]{2})/) { Regexp.last_match(1).hex.chr }
58
+ return nil if decoded.include?("\0") || decoded.include?('\\')
59
+
60
+ parts = decoded.split('/').reject { |p| p.empty? || p == '.' }
61
+ return nil if parts.any? { |p| p == '..' || p.start_with?('.') }
62
+
63
+ parts.join('/')
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VueLive
4
+ # Rewrites the selectors of a <style scoped> block so they only match elements carrying the
5
+ # component's scope attribute, exactly like @vue/compiler-sfc does:
6
+ #
7
+ # .a .b -> .a .b[data-v-xxx]
8
+ # .a :deep(.b) -> .a[data-v-xxx] .b
9
+ # .a ::v-deep .b -> .a[data-v-xxx] .b
10
+ # :slotted(.a) -> .a[data-v-xxx-s]
11
+ # :global(.a) -> .a
12
+ # @keyframes -> names left alone (animations are not scoped)
13
+ #
14
+ # The implementation is a small, forgiving CSS walker; it does not validate the stylesheet.
15
+ class ScopedCSS
16
+ def self.rewrite(css, scope_id)
17
+ new(scope_id).rewrite(css)
18
+ end
19
+
20
+ def initialize(scope_id)
21
+ @attr = "[#{scope_id}]"
22
+ @slot_attr = "[#{scope_id}-s]"
23
+ end
24
+
25
+ def rewrite(css)
26
+ out = +''
27
+ process(strip_comments(css), out, scope: true)
28
+ out
29
+ end
30
+
31
+ private
32
+
33
+ def strip_comments(css)
34
+ css.gsub(%r{/\*.*?\*/}m, '')
35
+ end
36
+
37
+ # Walk a stylesheet (or the body of a conditional at-rule) and append the rewritten text.
38
+ def process(css, out, scope:)
39
+ i = 0
40
+ len = css.length
41
+ while i < len
42
+ # whitespace between rules
43
+ if css[i] =~ /\s/
44
+ out << css[i]
45
+ i += 1
46
+ next
47
+ end
48
+
49
+ brace = find_rule_boundary(css, i)
50
+ if brace.nil?
51
+ out << css[i..]
52
+ break
53
+ end
54
+
55
+ kind, open_idx = brace
56
+ head = css[i...open_idx]
57
+
58
+ if kind == :statement # e.g. @import ...; @charset ...;
59
+ out << head << ';'
60
+ i = open_idx + 1
61
+ next
62
+ end
63
+
64
+ close_idx = matching_brace(css, open_idx)
65
+ body = css[(open_idx + 1)...close_idx]
66
+
67
+ if head.lstrip.start_with?('@')
68
+ name = head.strip[/\A@([\w-]+)/, 1].to_s.downcase
69
+ out << head << '{'
70
+ if %w[media supports layer container document].include?(name)
71
+ process(body, out, scope: scope)
72
+ else
73
+ # @keyframes, @font-face, @page, @property ... : copy verbatim
74
+ out << body
75
+ end
76
+ out << '}'
77
+ else
78
+ out << (scope ? rewrite_selector_list(head) : head) << '{' << body << '}'
79
+ end
80
+ i = close_idx + 1
81
+ end
82
+ end
83
+
84
+ # From +i+ find whether the next rule is a block ({) or a statement (;). Returns
85
+ # [:block, index_of_brace] or [:statement, index_of_semicolon], ignoring quoted strings.
86
+ def find_rule_boundary(css, i)
87
+ quote = nil
88
+ depth = 0
89
+ while i < css.length
90
+ c = css[i]
91
+ if quote
92
+ quote = nil if c == quote && css[i - 1] != '\\'
93
+ elsif ['"', "'"].include?(c)
94
+ quote = c
95
+ elsif c == '('
96
+ depth += 1
97
+ elsif c == ')'
98
+ depth -= 1
99
+ elsif depth.zero? && c == '{'
100
+ return [:block, i]
101
+ elsif depth.zero? && c == ';'
102
+ return [:statement, i]
103
+ end
104
+ i += 1
105
+ end
106
+ nil
107
+ end
108
+
109
+ def matching_brace(css, open_idx)
110
+ depth = 0
111
+ quote = nil
112
+ i = open_idx
113
+ while i < css.length
114
+ c = css[i]
115
+ if quote
116
+ quote = nil if c == quote && css[i - 1] != '\\'
117
+ elsif ['"', "'"].include?(c)
118
+ quote = c
119
+ elsif c == '{'
120
+ depth += 1
121
+ elsif c == '}'
122
+ depth -= 1
123
+ return i if depth.zero?
124
+ end
125
+ i += 1
126
+ end
127
+ css.length
128
+ end
129
+
130
+ def rewrite_selector_list(list)
131
+ leading = list[/\A\s*/]
132
+ trailing = list[/\s*\z/]
133
+ selectors = split_top_level(list.strip, ',')
134
+ leading + selectors.map { |s| rewrite_selector(s.strip) }.join(', ') + trailing
135
+ end
136
+
137
+ # Split on +sep+ while respecting parentheses and quotes.
138
+ def split_top_level(str, sep)
139
+ parts = []
140
+ depth = 0
141
+ quote = nil
142
+ buf = +''
143
+ str.each_char.with_index do |c, idx|
144
+ if quote
145
+ quote = nil if c == quote && str[idx - 1] != '\\'
146
+ buf << c
147
+ elsif ['"', "'"].include?(c)
148
+ quote = c
149
+ buf << c
150
+ elsif c == '('
151
+ depth += 1
152
+ buf << c
153
+ elsif c == ')'
154
+ depth -= 1
155
+ buf << c
156
+ elsif c == sep && depth.zero?
157
+ parts << buf
158
+ buf = +''
159
+ else
160
+ buf << c
161
+ end
162
+ end
163
+ parts << buf
164
+ parts
165
+ end
166
+
167
+ def rewrite_selector(sel)
168
+ return sel if sel.empty?
169
+
170
+ # :global(...) escapes scoping entirely.
171
+ if (m = sel.match(/\A:global\((.*)\)\z/m))
172
+ return m[1].strip
173
+ end
174
+
175
+ # :deep(x) / ::v-deep x / >>> x / /deep/ x : scope the part *before*, leave the rest alone.
176
+ if (m = sel.match(%r{\A(.*?)(?:\s*::v-deep\((.*?)\)|\s*:deep\((.*?)\)|\s*::v-deep\b|\s*>>>|\s*/deep/)(.*)\z}m))
177
+ before = m[1].strip
178
+ inner = (m[2] || m[3]).to_s.strip
179
+ after = m[4].to_s.strip
180
+ rest = [inner, after].reject(&:empty?).join(' ')
181
+ return rest if before.empty? # ":deep(.a)" alone scopes nothing
182
+
183
+ return "#{scope_compound(before)} #{rest}".strip
184
+ end
185
+
186
+ # :slotted(x) -> x gets the slot scope attribute.
187
+ if (m = sel.match(/\A(.*?):slotted\((.*)\)(.*)\z/m))
188
+ before = m[1].strip
189
+ inner = m[2].strip
190
+ after = m[3].strip
191
+ scoped_inner = scope_compound(inner, attr: @slot_attr)
192
+ return [before, scoped_inner + after].reject(&:empty?).join(' ')
193
+ end
194
+
195
+ scope_compound(sel)
196
+ end
197
+
198
+ # Append the scope attribute to the last compound selector, before any pseudo-element.
199
+ def scope_compound(sel, attr: @attr)
200
+ sel = sel.strip
201
+ return sel if sel.empty?
202
+
203
+ compounds = split_compounds(sel)
204
+ last = compounds.pop
205
+ compounds.push(insert_attr(last, attr))
206
+ compounds.join
207
+ end
208
+
209
+ # Split "a > b ~ c" into ["a > ", "b ~ ", "c"] keeping combinators attached to the previous part.
210
+ def split_compounds(sel)
211
+ parts = []
212
+ buf = +''
213
+ depth = 0
214
+ quote = nil
215
+ i = 0
216
+ while i < sel.length
217
+ c = sel[i]
218
+ if quote
219
+ quote = nil if c == quote && sel[i - 1] != '\\'
220
+ buf << c
221
+ elsif ['"', "'"].include?(c)
222
+ quote = c
223
+ buf << c
224
+ elsif c == '('
225
+ depth += 1
226
+ buf << c
227
+ elsif c == ')'
228
+ depth -= 1
229
+ buf << c
230
+ elsif depth.zero? && (c =~ /\s/ || c == '>' || c == '+' || c == '~')
231
+ # consume the whole combinator run
232
+ j = i
233
+ j += 1 while j < sel.length && (sel[j] =~ /\s/ || sel[j] == '>' || sel[j] == '+' || sel[j] == '~')
234
+ buf << sel[i...j]
235
+ parts << buf
236
+ buf = +''
237
+ i = j
238
+ next
239
+ else
240
+ buf << c
241
+ end
242
+ i += 1
243
+ end
244
+ parts << buf unless buf.empty?
245
+ parts
246
+ end
247
+
248
+ def insert_attr(compound, attr)
249
+ # Split off pseudo-elements (::before, :before, :after, :first-line...) which must come last.
250
+ if (m = compound.match(/\A(.*?)((?:::[\w-]+(?:\([^)]*\))?|:(?:before|after|first-line|first-letter|selection|placeholder|marker|backdrop)\b)+)\z/m)) # rubocop:disable Layout/LineLength
251
+ base = m[1]
252
+ pseudo = m[2]
253
+ return "#{base.empty? ? '*' : base}#{attr}#{pseudo}"
254
+ end
255
+ compound.empty? ? "*#{attr}" : "#{compound}#{attr}"
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VueLive
4
+ module SFC
5
+ # One top-level block of a single-file component: <template>, <script>, <style> or a custom block.
6
+ class Block
7
+ attr_reader :type, :content, :attrs, :line
8
+
9
+ def initialize(type:, content:, attrs: {}, line: 1)
10
+ @type = type
11
+ @content = content
12
+ @attrs = attrs
13
+ @line = line
14
+ end
15
+
16
+ def lang
17
+ attrs['lang']
18
+ end
19
+
20
+ def src
21
+ attrs['src']
22
+ end
23
+
24
+ def setup?
25
+ attrs.key?('setup')
26
+ end
27
+
28
+ def scoped?
29
+ attrs.key?('scoped')
30
+ end
31
+
32
+ def module?
33
+ attrs.key?('module')
34
+ end
35
+
36
+ def attr?(name)
37
+ attrs.key?(name)
38
+ end
39
+ end
40
+
41
+ # The parsed shape of a .vue file. Mirrors @vue/compiler-sfc's SFCDescriptor closely enough
42
+ # that both compiler backends can reason about the same thing.
43
+ class Descriptor
44
+ attr_reader :filename, :source, :template, :script, :script_setup, :styles, :custom_blocks
45
+
46
+ def initialize(filename:, source:, template: nil, script: nil, script_setup: nil, styles: [], custom_blocks: [])
47
+ @filename = filename
48
+ @source = source
49
+ @template = template
50
+ @script = script
51
+ @script_setup = script_setup
52
+ @styles = styles
53
+ @custom_blocks = custom_blocks
54
+ end
55
+
56
+ def scoped_styles?
57
+ styles.any?(&:scoped?)
58
+ end
59
+
60
+ def css_modules?
61
+ styles.any?(&:module?)
62
+ end
63
+
64
+ def template_lang
65
+ template&.lang
66
+ end
67
+
68
+ def script_lang
69
+ (script_setup || script)&.lang
70
+ end
71
+
72
+ # Features the pure Ruby backend cannot provide. Empty means "Ruby can compile this".
73
+ def advanced_features
74
+ features = []
75
+ features << '<script setup>' if script_setup
76
+ features << "<script lang=\"#{script_lang}\">" if script_lang && !%w[js javascript].include?(script_lang)
77
+ features << "<template lang=\"#{template_lang}\">" if template_lang && template_lang != 'html'
78
+ styles.each do |s|
79
+ features << "<style lang=\"#{s.lang}\">" if s.lang && !%w[css postcss].include?(s.lang)
80
+ features << '<style module>' if s.module?
81
+ end
82
+ features.uniq
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'descriptor'
4
+
5
+ module VueLive
6
+ module SFC
7
+ # Splits a .vue file into its top-level blocks.
8
+ #
9
+ # Only the top level is parsed; the contents of <template> are handed to Vue's own compiler
10
+ # untouched, so nested <template v-slot> elements, comments and so on survive as-is.
11
+ class Parser
12
+ BLOCK_START = %r{<(template|script|style|[a-zA-Z][\w-]*)(\s[^>]*?)?(/?)>}m
13
+ ATTR = %r{([^\s=/"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?}
14
+
15
+ def self.parse(source, filename: 'anonymous.vue')
16
+ new(source, filename).parse
17
+ end
18
+
19
+ def initialize(source, filename)
20
+ @source = source
21
+ @filename = filename
22
+ end
23
+
24
+ def parse
25
+ template = nil
26
+ script = nil
27
+ script_setup = nil
28
+ styles = []
29
+ custom = []
30
+
31
+ pos = 0
32
+ while (m = BLOCK_START.match(@source, pos))
33
+ tag = m[1]
34
+ attrs = parse_attrs(m[2].to_s)
35
+ self_close = m[3] == '/'
36
+ start = m.end(0)
37
+ line = @source[0...m.begin(0)].count("\n") + 1
38
+
39
+ if self_close
40
+ content = ''
41
+ pos = start
42
+ else
43
+ close = find_close(tag, start)
44
+ raise CompileError.new("unterminated <#{tag}> block starting on line #{line}", file: @filename) unless close
45
+
46
+ content = @source[start...close[0]]
47
+ pos = close[1]
48
+ end
49
+
50
+ block = Block.new(type: tag, content: content, attrs: attrs, line: line)
51
+ case tag
52
+ when 'template'
53
+ raise CompileError.new('only one top-level <template> block is allowed', file: @filename) if template
54
+
55
+ template = block
56
+ when 'script'
57
+ if block.setup?
58
+ raise CompileError.new('only one <script setup> block is allowed', file: @filename) if script_setup
59
+
60
+ script_setup = block
61
+ else
62
+ raise CompileError.new('only one plain <script> block is allowed', file: @filename) if script
63
+
64
+ script = block
65
+ end
66
+ when 'style'
67
+ styles << block
68
+ else
69
+ custom << block
70
+ end
71
+ end
72
+
73
+ if template.nil? && script.nil? && script_setup.nil?
74
+ raise CompileError.new('at least a <template> or <script> block is required', file: @filename)
75
+ end
76
+
77
+ Descriptor.new(filename: @filename, source: @source, template: template, script: script,
78
+ script_setup: script_setup, styles: styles, custom_blocks: custom)
79
+ end
80
+
81
+ private
82
+
83
+ # Returns [content_end, after_close_tag] for the block opened at +start+.
84
+ def find_close(tag, start)
85
+ if tag == 'template'
86
+ # Templates nest (<template v-slot>), so balance open/close tags.
87
+ depth = 1
88
+ scan = start
89
+ re = %r{<(/?)template(?=[\s>/])[^>]*?(/?)>}m
90
+ while (m = re.match(@source, scan))
91
+ if m[1] == '/'
92
+ depth -= 1
93
+ return [m.begin(0), m.end(0)] if depth.zero?
94
+ elsif m[2] != '/'
95
+ depth += 1
96
+ end
97
+ scan = m.end(0)
98
+ end
99
+ nil
100
+ else
101
+ idx = @source.index(%r{</#{Regexp.escape(tag)}\s*>}i, start)
102
+ return nil unless idx
103
+
104
+ close_end = @source.index('>', idx) + 1
105
+ [idx, close_end]
106
+ end
107
+ end
108
+
109
+ def parse_attrs(str)
110
+ attrs = {}
111
+ str.scan(ATTR) do |name, dq, sq, bare|
112
+ attrs[name] = dq || sq || bare || ''
113
+ end
114
+ attrs
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../vue_live'
4
+
5
+ module VueLive
6
+ # Sinatra extension:
7
+ #
8
+ # require 'vue_live/sinatra'
9
+ # class App < Sinatra::Base
10
+ # set :vue_live, source_path: 'app/vue', prefix: '/vue' # optional, before register
11
+ # register VueLive::Sinatra
12
+ # end
13
+ #
14
+ # Classic-style apps call `register VueLive::Sinatra` at the top level.
15
+ #
16
+ # Mounts the middleware (unless config.middleware is false), defaults the component root to the
17
+ # app's own root and adds the view helpers.
18
+ module Sinatra
19
+ def self.registered(app)
20
+ app.helpers VueLive::Helpers
21
+ app.set :vue_live, {} unless app.respond_to?(:vue_live)
22
+
23
+ app.configure do
24
+ VueLive.config.root = app.root if app.respond_to?(:root) && app.root && !VueLive.rails?
25
+ VueLive.config.env = app.environment.to_s if app.respond_to?(:environment) && app.environment
26
+ VueLive.config.apply(app.vue_live || {})
27
+ VueLive.load_config_file
28
+ app.use VueLive::Middleware if VueLive.config.middleware
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module VueLive
7
+ # Minimal source map (v3) writer: enough to map generated lines back to lines of the .vue file.
8
+ #
9
+ # map = SourceMap.new('App.vue', source)
10
+ # map.add(generated_line: 3, original_line: 12) # 1-based
11
+ # map.to_h / map.to_json / map.inline_comment
12
+ class SourceMap
13
+ BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14
+
15
+ attr_reader :file, :source
16
+
17
+ def initialize(file, source, generated_file: "#{file}.js")
18
+ @file = file
19
+ @source = source
20
+ @generated_file = generated_file
21
+ @mappings = {} # generated line (1-based) => [[gen_col, orig_line, orig_col], ...]
22
+ end
23
+
24
+ def add(generated_line:, original_line:, generated_column: 0, original_column: 0)
25
+ (@mappings[generated_line] ||= []) << [generated_column, original_line, original_column]
26
+ self
27
+ end
28
+
29
+ # Map +count+ consecutive generated lines starting at +generated_line+ to the source lines
30
+ # starting at +original_line+.
31
+ def add_block(generated_line:, original_line:, count:)
32
+ count.times { |i| add(generated_line: generated_line + i, original_line: original_line + i) }
33
+ self
34
+ end
35
+
36
+ def empty?
37
+ @mappings.empty?
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ 'version' => 3,
43
+ 'file' => @generated_file,
44
+ 'sources' => [file],
45
+ 'sourcesContent' => [source],
46
+ 'names' => [],
47
+ 'mappings' => encode_mappings
48
+ }
49
+ end
50
+
51
+ def to_json(*)
52
+ JSON.generate(to_h)
53
+ end
54
+
55
+ # `//# sourceMappingURL=data:...` line for a map Hash (this one or a backend-supplied one).
56
+ def self.inline_comment(map_hash)
57
+ data = Base64.strict_encode64(JSON.generate(map_hash))
58
+ "//# sourceMappingURL=data:application/json;charset=utf-8;base64,#{data}\n"
59
+ end
60
+
61
+ def inline_comment
62
+ self.class.inline_comment(to_h)
63
+ end
64
+
65
+ def self.vlq(value)
66
+ vlq = value.negative? ? ((-value) << 1) | 1 : value << 1
67
+ out = +''
68
+ loop do
69
+ digit = vlq & 0x1f
70
+ vlq >>= 5
71
+ digit |= 0x20 if vlq.positive?
72
+ out << BASE64[digit]
73
+ break unless vlq.positive?
74
+ end
75
+ out
76
+ end
77
+
78
+ private
79
+
80
+ def encode_mappings
81
+ last_line = @mappings.keys.max || 0
82
+ prev_orig_line = 0
83
+ prev_orig_col = 0
84
+ (1..last_line).map do |line|
85
+ segments = (@mappings[line] || []).sort_by(&:first)
86
+ prev_gen_col = 0
87
+ segments.map do |gen_col, orig_line, orig_col|
88
+ seg = self.class.vlq(gen_col - prev_gen_col) +
89
+ self.class.vlq(0) + # source index (always the one .vue file)
90
+ self.class.vlq((orig_line - 1) - prev_orig_line) +
91
+ self.class.vlq(orig_col - prev_orig_col)
92
+ prev_gen_col = gen_col
93
+ prev_orig_line = orig_line - 1
94
+ prev_orig_col = orig_col
95
+ seg
96
+ end.join(',')
97
+ end.join(';')
98
+ end
99
+ end
100
+ end