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
data/lib/vue_live/cli.rb
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative '../vue_live'
|
|
5
|
+
|
|
6
|
+
module VueLive
|
|
7
|
+
# The `vue-live` executable.
|
|
8
|
+
class CLI
|
|
9
|
+
TEMPLATE_DIR = File.expand_path('templates', __dir__)
|
|
10
|
+
|
|
11
|
+
USAGE = <<~TEXT.freeze
|
|
12
|
+
vue-live #{VueLive::VERSION}
|
|
13
|
+
|
|
14
|
+
Usage: vue-live <command> [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
init Create config/vue_live.yml and app/vue/ with an example component
|
|
18
|
+
--force overwrite existing files
|
|
19
|
+
--node also install @vue/compiler-sfc + vue (npm/yarn)
|
|
20
|
+
compile Write static .vue.js modules + manifest.json (--out DIR, default public/vue)
|
|
21
|
+
clobber Remove precompiled output and the compile cache
|
|
22
|
+
check Verify configuration, components and the Node toolchain
|
|
23
|
+
node-setup Install @vue/compiler-sfc and vue (--with pkg[,pkg] e.g. --with sass,esbuild);
|
|
24
|
+
adds the sucrase TypeScript transpiler when Node.js < 22.13
|
|
25
|
+
info Show versions and resolved settings
|
|
26
|
+
version Show the vue_live version
|
|
27
|
+
|
|
28
|
+
The environment comes from VUE_LIVE_ENV, RAILS_ENV, RACK_ENV or APP_ENV (default: development).
|
|
29
|
+
In a Rails project prefer `bin/rails generate vue_live:install` and the `vue_live:*` rake tasks.
|
|
30
|
+
TEXT
|
|
31
|
+
|
|
32
|
+
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
33
|
+
new(argv, out, err).run
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(argv, out = $stdout, err = $stderr)
|
|
37
|
+
@command, *@args = argv
|
|
38
|
+
@out = out
|
|
39
|
+
@err = err
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
VueLive.load_config_file if %w[compile clobber check info].include?(@command)
|
|
44
|
+
case @command
|
|
45
|
+
when 'init' then init
|
|
46
|
+
when 'compile' then compile
|
|
47
|
+
when 'clobber' then clobber
|
|
48
|
+
when 'check' then check
|
|
49
|
+
when 'node-setup' then node_setup
|
|
50
|
+
when 'info' then info
|
|
51
|
+
when 'version', '--version', '-v' then @out.puts VueLive::VERSION
|
|
52
|
+
when nil, 'help', '--help', '-h' then @out.puts USAGE
|
|
53
|
+
else
|
|
54
|
+
@err.puts "Unknown command: #{@command}\n\n#{USAGE}"
|
|
55
|
+
return 1
|
|
56
|
+
end
|
|
57
|
+
0
|
|
58
|
+
rescue VueLive::Error => e
|
|
59
|
+
@err.puts e.message
|
|
60
|
+
1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def root
|
|
66
|
+
VueLive.config.root
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def init
|
|
70
|
+
force = @args.include?('--force')
|
|
71
|
+
if VueLive.rails_project?(root)
|
|
72
|
+
@out.puts 'Rails project detected: creating the same files `bin/rails generate vue_live:install` would.'
|
|
73
|
+
end
|
|
74
|
+
write('config/vue_live.yml', File.read(File.join(TEMPLATE_DIR, 'vue_live.yml')), force)
|
|
75
|
+
FileUtils.mkdir_p(File.join(root, VueLive.config.source_path))
|
|
76
|
+
write(File.join(VueLive.config.source_path, 'HelloVueLive.vue'), File.read(File.join(TEMPLATE_DIR, 'HelloVueLive.vue')),
|
|
77
|
+
force)
|
|
78
|
+
NodeTools.setup(root) if @args.include?('--node')
|
|
79
|
+
@out.puts ''
|
|
80
|
+
@out.puts 'Done. Mount the middleware (`use VueLive::Middleware`, or `register VueLive::Sinatra`) and render:'
|
|
81
|
+
@out.puts ''
|
|
82
|
+
@out.puts ' <%= vue_live_import_map_tag %>'
|
|
83
|
+
@out.puts ' <%= vue_live_mount_tag "HelloVueLive.vue", "#app", element: true %>'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def compile
|
|
87
|
+
out_dir = option_value('--out') || VueLive.config.precompile_dir
|
|
88
|
+
manifest = Precompiler.new.run(output: File.expand_path(out_dir, root), out: @out)
|
|
89
|
+
@out.puts " #{manifest.size} file(s) written to #{out_dir}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clobber
|
|
93
|
+
Precompiler.new.clobber
|
|
94
|
+
VueLive.store.clear
|
|
95
|
+
@out.puts " removed #{VueLive.config.precompile_dir} and #{VueLive.config.cache_dir}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check
|
|
99
|
+
problems = NodeTools.problems
|
|
100
|
+
components = VueLive.store.resolver.components
|
|
101
|
+
@out.puts " components: #{VueLive.config.source_dir} (#{components.size} .vue file(s))"
|
|
102
|
+
components.each do |c|
|
|
103
|
+
compiled = VueLive.store.fetch(c)
|
|
104
|
+
@out.puts " ok #{c} (#{compiled.backend})"
|
|
105
|
+
rescue VueLive::Error => e
|
|
106
|
+
problems << e.message
|
|
107
|
+
@out.puts " FAIL #{c}"
|
|
108
|
+
end
|
|
109
|
+
if problems.empty?
|
|
110
|
+
@out.puts 'Everything looks good.'
|
|
111
|
+
else
|
|
112
|
+
problems.each { |p| @out.puts " problem: #{p}" }
|
|
113
|
+
raise Error, "#{problems.size} problem(s) found"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def node_setup
|
|
118
|
+
extra = option_value('--with').to_s.split(',').map(&:strip).reject(&:empty?)
|
|
119
|
+
NodeTools.setup(root, packages: NodeTools::PACKAGES + extra)
|
|
120
|
+
@out.puts 'Node backend ready: set `compiler: node` (or leave `auto`) in config/vue_live.yml.'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def info
|
|
124
|
+
cfg = VueLive.config
|
|
125
|
+
{
|
|
126
|
+
'vue_live' => VueLive::VERSION,
|
|
127
|
+
'ruby' => RUBY_VERSION,
|
|
128
|
+
'root' => cfg.root,
|
|
129
|
+
'env' => cfg.env,
|
|
130
|
+
'rails project' => VueLive.rails_project?(cfg.root),
|
|
131
|
+
'components' => cfg.source_dir,
|
|
132
|
+
'prefix' => cfg.normalized_prefix,
|
|
133
|
+
'compiler' => cfg.compiler,
|
|
134
|
+
'cache' => cfg.cache,
|
|
135
|
+
'vue url' => cfg.resolved_vue_url,
|
|
136
|
+
'node' => NodeTools.node_version || 'not found',
|
|
137
|
+
'@vue/compiler-sfc' => NodeTools.compiler_sfc_version || 'not installed',
|
|
138
|
+
'package manager' => NodeTools.package_manager,
|
|
139
|
+
'webpacker_cli' => NodeTools.webpacker_cli? ? 'available' : 'not installed'
|
|
140
|
+
}.each { |k, v| @out.puts "#{k}: #{v}" }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def write(rel, content, force)
|
|
144
|
+
path = File.join(root, rel)
|
|
145
|
+
if File.exist?(path) && !force
|
|
146
|
+
@out.puts " exist #{rel}"
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
150
|
+
File.write(path, content)
|
|
151
|
+
@out.puts " create #{rel}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def option_value(flag)
|
|
155
|
+
idx = @args.index(flag)
|
|
156
|
+
return @args[idx + 1] if idx && @args[idx + 1]
|
|
157
|
+
|
|
158
|
+
@args.find { |a| a.start_with?("#{flag}=") }&.split('=', 2)&.last
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Compiles .vue files with @vue/compiler-sfc. Driven by lib/vue_live/compiler/node.rb.
|
|
3
|
+
//
|
|
4
|
+
// One-shot: node compile.js <project-root> < request JSON
|
|
5
|
+
// Worker: node compile.js <project-root> --server (one request JSON per stdin line,
|
|
6
|
+
// one response JSON per stdout line)
|
|
7
|
+
//
|
|
8
|
+
// Request: { id?, source, filename, absolutePath?, scopeId, isProd, sourceMap? }
|
|
9
|
+
// Response: { id?, code, css, map?, scoped, dependencies, errors, tips }
|
|
10
|
+
//
|
|
11
|
+
// Style injection and the `.vue` -> `.vue.js` import rewrite happen on the Ruby side so both
|
|
12
|
+
// backends share them.
|
|
13
|
+
'use strict'
|
|
14
|
+
|
|
15
|
+
// node:module#stripTypeScriptTypes prints an ExperimentalWarning; keep stdout/stderr clean for Ruby.
|
|
16
|
+
process.removeAllListeners('warning')
|
|
17
|
+
process.on('warning', () => {})
|
|
18
|
+
|
|
19
|
+
const path = require('path')
|
|
20
|
+
const args = process.argv.slice(2)
|
|
21
|
+
const serverMode = args.includes('--server')
|
|
22
|
+
const root = args.find(a => !a.startsWith('--')) || process.cwd()
|
|
23
|
+
|
|
24
|
+
function resolveFrom(name, from) {
|
|
25
|
+
return require(require.resolve(name, { paths: [from, root, process.cwd(), __dirname] }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let sfc
|
|
29
|
+
let sfcDir
|
|
30
|
+
try {
|
|
31
|
+
const entry = require.resolve('@vue/compiler-sfc', { paths: [root, process.cwd(), __dirname] })
|
|
32
|
+
sfc = require(entry)
|
|
33
|
+
sfcDir = path.dirname(entry)
|
|
34
|
+
} catch (e) {
|
|
35
|
+
const err = { errors: ['@vue/compiler-sfc is not installed in ' + root + ' (run `vue-live node-setup`)'] }
|
|
36
|
+
process.stdout.write(JSON.stringify(err) + '\n')
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// source-map-js is a dependency of @vue/compiler-sfc; use it to merge the script and template maps.
|
|
41
|
+
let sourceMapJs = null
|
|
42
|
+
try { sourceMapJs = resolveFrom('source-map-js', sfcDir) } catch (e) { /* source maps unavailable */ }
|
|
43
|
+
|
|
44
|
+
if (serverMode) {
|
|
45
|
+
serve()
|
|
46
|
+
} else {
|
|
47
|
+
let input = ''
|
|
48
|
+
process.stdin.setEncoding('utf8')
|
|
49
|
+
process.stdin.on('data', chunk => { input += chunk })
|
|
50
|
+
process.stdin.on('end', () => {
|
|
51
|
+
handle(JSON.parse(input)).then(
|
|
52
|
+
result => { process.stdout.write(JSON.stringify(result) + '\n'); process.exit(0) },
|
|
53
|
+
err => { process.stdout.write(JSON.stringify({ errors: [String(err && err.stack || err)] }) + '\n'); process.exit(1) }
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Long-lived worker: requests are processed strictly in order so Ruby can pair responses by id.
|
|
59
|
+
function serve() {
|
|
60
|
+
const readline = require('readline')
|
|
61
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
62
|
+
let queue = Promise.resolve()
|
|
63
|
+
rl.on('line', line => {
|
|
64
|
+
if (!line.trim()) return
|
|
65
|
+
queue = queue.then(async () => {
|
|
66
|
+
let request = {}
|
|
67
|
+
let response
|
|
68
|
+
try {
|
|
69
|
+
request = JSON.parse(line)
|
|
70
|
+
response = await handle(request)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
response = { errors: [String(err && err.stack || err)] }
|
|
73
|
+
}
|
|
74
|
+
response.id = request.id
|
|
75
|
+
process.stdout.write(JSON.stringify(response) + '\n')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
rl.on('close', () => process.exit(0))
|
|
79
|
+
process.stdin.on('end', () => process.exit(0))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handle(opts) {
|
|
83
|
+
try {
|
|
84
|
+
return await compile(opts)
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { errors: [formatError(err)] }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function compile(opts) {
|
|
91
|
+
const { source, filename, scopeId, isProd } = opts
|
|
92
|
+
const wantMap = !!opts.sourceMap && !!sourceMapJs
|
|
93
|
+
const absolutePath = opts.absolutePath || path.join(root, filename)
|
|
94
|
+
const id = scopeId.replace(/^data-v-/, '')
|
|
95
|
+
const tips = []
|
|
96
|
+
const dependencies = []
|
|
97
|
+
|
|
98
|
+
const { descriptor, errors: parseErrors } = sfc.parse(source, { filename: absolutePath, sourceMap: wantMap })
|
|
99
|
+
if (parseErrors.length) return { errors: parseErrors.map(formatError) }
|
|
100
|
+
|
|
101
|
+
const hasScoped = descriptor.styles.some(s => s.scoped)
|
|
102
|
+
const hasSetup = !!descriptor.scriptSetup
|
|
103
|
+
const inlineTemplate = isProd && hasSetup
|
|
104
|
+
const parts = [] // { code, map? } concatenated with "\n"
|
|
105
|
+
let bindings
|
|
106
|
+
|
|
107
|
+
const templateOptions = {
|
|
108
|
+
filename: absolutePath,
|
|
109
|
+
id,
|
|
110
|
+
scoped: hasScoped,
|
|
111
|
+
slotted: descriptor.slotted,
|
|
112
|
+
isProd,
|
|
113
|
+
preprocessLang: descriptor.template && descriptor.template.lang,
|
|
114
|
+
compilerOptions: { mode: 'module' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (descriptor.script || descriptor.scriptSetup) {
|
|
118
|
+
const script = sfc.compileScript(descriptor, {
|
|
119
|
+
id,
|
|
120
|
+
isProd,
|
|
121
|
+
inlineTemplate,
|
|
122
|
+
genDefaultAs: '__sfc__',
|
|
123
|
+
templateOptions,
|
|
124
|
+
sourceMap: wantMap
|
|
125
|
+
})
|
|
126
|
+
let code = script.content
|
|
127
|
+
let map = script.map
|
|
128
|
+
bindings = script.bindings
|
|
129
|
+
// Older compiler-sfc versions ignore genDefaultAs; normalise either way.
|
|
130
|
+
if (/^\s*export\s+default\b/m.test(code) && !/\bconst __sfc__\b/.test(code)) {
|
|
131
|
+
code = sfc.rewriteDefault(code, '__sfc__')
|
|
132
|
+
}
|
|
133
|
+
if (script.warnings) script.warnings.forEach(w => tips.push(String(w)))
|
|
134
|
+
const lang = (descriptor.scriptSetup && descriptor.scriptSetup.lang) || (descriptor.script && descriptor.script.lang)
|
|
135
|
+
if (lang && /^tsx?$/.test(lang)) {
|
|
136
|
+
const stripped = stripTypes(code, absolutePath, lang)
|
|
137
|
+
code = stripped.code
|
|
138
|
+
if (!stripped.preservesLines) map = null
|
|
139
|
+
}
|
|
140
|
+
parts.push({ code, map })
|
|
141
|
+
} else {
|
|
142
|
+
parts.push({ code: 'const __sfc__ = {}' })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (descriptor.template && !inlineTemplate) {
|
|
146
|
+
const tpl = sfc.compileTemplate(Object.assign({}, templateOptions, {
|
|
147
|
+
source: descriptor.template.content,
|
|
148
|
+
inMap: wantMap ? descriptor.template.map : undefined,
|
|
149
|
+
compilerOptions: { mode: 'module', bindingMetadata: bindings, sourceMap: wantMap }
|
|
150
|
+
}))
|
|
151
|
+
if (tpl.errors && tpl.errors.length) return { errors: tpl.errors.map(formatError) }
|
|
152
|
+
if (tpl.tips) tpl.tips.forEach(t => tips.push(String(t)))
|
|
153
|
+
const code = tpl.code.replace(/\nexport\s+(function|const)\s+render\b/, '\n$1 render') + '\n__sfc__.render = render'
|
|
154
|
+
parts.push({ code, map: tpl.map })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const cssParts = []
|
|
158
|
+
const cssModules = {}
|
|
159
|
+
for (const style of descriptor.styles) {
|
|
160
|
+
const result = await sfc.compileStyleAsync({
|
|
161
|
+
source: style.content,
|
|
162
|
+
filename: absolutePath,
|
|
163
|
+
id: scopeId,
|
|
164
|
+
scoped: !!style.scoped,
|
|
165
|
+
modules: !!style.module,
|
|
166
|
+
preprocessLang: style.lang,
|
|
167
|
+
isProd
|
|
168
|
+
})
|
|
169
|
+
if (result.errors && result.errors.length) return { errors: result.errors.map(formatError) }
|
|
170
|
+
cssParts.push(result.code)
|
|
171
|
+
if (style.module) {
|
|
172
|
+
const name = typeof style.module === 'string' ? style.module : '$style'
|
|
173
|
+
cssModules[name] = result.modules || {}
|
|
174
|
+
}
|
|
175
|
+
if (result.dependencies) for (const d of result.dependencies) dependencies.push(d)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let tail = ''
|
|
179
|
+
if (hasScoped) tail += `\n__sfc__.__scopeId = ${JSON.stringify(scopeId)}`
|
|
180
|
+
if (Object.keys(cssModules).length) tail += `\n__sfc__.__cssModules = ${JSON.stringify(cssModules)}`
|
|
181
|
+
if (!isProd) tail += `\n__sfc__.__file = ${JSON.stringify(filename)}`
|
|
182
|
+
tail += '\nexport default __sfc__\n'
|
|
183
|
+
parts.push({ code: tail.replace(/^\n/, '') })
|
|
184
|
+
|
|
185
|
+
const code = parts.map(p => p.code).join('\n')
|
|
186
|
+
const map = wantMap ? mergeMaps(parts, filename, source) : undefined
|
|
187
|
+
return { code, css: cssParts.join('\n'), map, scoped: hasScoped, dependencies, errors: [], tips }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Concatenate the per-block source maps, shifting generated lines by each block's offset. All
|
|
191
|
+
// blocks map back to the single .vue file.
|
|
192
|
+
function mergeMaps(parts, filename, source) {
|
|
193
|
+
const generator = new sourceMapJs.SourceMapGenerator({ file: filename + '.js' })
|
|
194
|
+
let lineOffset = 0
|
|
195
|
+
for (const part of parts) {
|
|
196
|
+
if (part.map) {
|
|
197
|
+
try {
|
|
198
|
+
const consumer = new sourceMapJs.SourceMapConsumer(typeof part.map === 'string' ? JSON.parse(part.map) : part.map)
|
|
199
|
+
consumer.eachMapping(m => {
|
|
200
|
+
if (m.originalLine == null) return
|
|
201
|
+
generator.addMapping({
|
|
202
|
+
generated: { line: m.generatedLine + lineOffset, column: m.generatedColumn },
|
|
203
|
+
original: { line: m.originalLine, column: m.originalColumn || 0 },
|
|
204
|
+
source: filename,
|
|
205
|
+
name: m.name || undefined
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
} catch (e) { /* a block without a usable map is simply unmapped */ }
|
|
209
|
+
}
|
|
210
|
+
lineOffset += part.code.split('\n').length
|
|
211
|
+
}
|
|
212
|
+
generator.setSourceContent(filename, source)
|
|
213
|
+
return generator.toJSON()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// @vue/compiler-sfc leaves TypeScript syntax in place (bundlers strip it later), so do it here with
|
|
217
|
+
// whichever transpiler the project has. Returns { code, preservesLines }; a transpiler that keeps
|
|
218
|
+
// line numbers lets the script source map stay valid.
|
|
219
|
+
function stripTypes(code, filename, lang) {
|
|
220
|
+
const resolve = name => require(require.resolve(name, { paths: [root, process.cwd()] }))
|
|
221
|
+
const attempts = {
|
|
222
|
+
// Node.js >= 22.13 ships a type stripper (swc via amaro): no install needed. 'strip' mode
|
|
223
|
+
// replaces types with whitespace and keeps every line in place.
|
|
224
|
+
node: () => {
|
|
225
|
+
const strip = require('node:module').stripTypeScriptTypes
|
|
226
|
+
if (typeof strip !== 'function') throw notFound('node:module#stripTypeScriptTypes (Node.js >= 22.13)')
|
|
227
|
+
try { return { code: strip(code, { mode: 'strip' }), preservesLines: true } } catch (e) {
|
|
228
|
+
return { code: strip(code, { mode: 'transform' }), preservesLines: false }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
sucrase: () => ({ code: resolve('sucrase').transform(code, { transforms: lang === 'tsx' ? ['typescript', 'jsx'] : ['typescript'], filePath: filename }).code, preservesLines: true }),
|
|
232
|
+
esbuild: () => ({ code: resolve('esbuild').transformSync(code, { loader: lang, format: 'esm', target: 'es2020', sourcefile: filename }).code, preservesLines: false }),
|
|
233
|
+
typescript: () => {
|
|
234
|
+
const ts = resolve('typescript')
|
|
235
|
+
// TypeScript 7 (the native port) exposes no transpile API from JavaScript.
|
|
236
|
+
if (typeof ts.transpileModule !== 'function') throw notFound('typescript#transpileModule (TypeScript < 7)')
|
|
237
|
+
const jsx = lang === 'tsx' ? ts.JsxEmit.Preserve : undefined
|
|
238
|
+
return {
|
|
239
|
+
code: ts.transpileModule(code, {
|
|
240
|
+
fileName: filename,
|
|
241
|
+
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020, jsx, verbatimModuleSyntax: true }
|
|
242
|
+
}).outputText,
|
|
243
|
+
preservesLines: false
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
babel: () => {
|
|
247
|
+
const babel = resolve('@babel/core')
|
|
248
|
+
let plugin
|
|
249
|
+
try { plugin = require.resolve('@babel/plugin-transform-typescript', { paths: [root, process.cwd()] }) } catch (e) {
|
|
250
|
+
plugin = require.resolve('@babel/preset-typescript', { paths: [root, process.cwd()] })
|
|
251
|
+
}
|
|
252
|
+
const isPreset = /preset-typescript/.test(plugin)
|
|
253
|
+
return {
|
|
254
|
+
code: babel.transformSync(code, {
|
|
255
|
+
filename, babelrc: false, configFile: false, sourceType: 'module', retainLines: true,
|
|
256
|
+
presets: isPreset ? [[plugin, { onlyRemoveTypeImports: true }]] : [],
|
|
257
|
+
plugins: isPreset ? [] : [[plugin, { onlyRemoveTypeImports: true, isTSX: lang === 'tsx' }]]
|
|
258
|
+
}).code,
|
|
259
|
+
preservesLines: true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// VUE_LIVE_TS_TRANSPILER=node|sucrase|esbuild|typescript|babel pins one; otherwise try in order.
|
|
265
|
+
const forced = process.env.VUE_LIVE_TS_TRANSPILER
|
|
266
|
+
const order = forced ? [forced] : Object.keys(attempts)
|
|
267
|
+
const failures = []
|
|
268
|
+
for (const name of order) {
|
|
269
|
+
const attempt = attempts[name]
|
|
270
|
+
if (!attempt) throw new Error('unknown VUE_LIVE_TS_TRANSPILER ' + JSON.stringify(forced) + '; expected one of ' + Object.keys(attempts).join(', '))
|
|
271
|
+
try { return attempt() } catch (e) { failures.push(e) }
|
|
272
|
+
}
|
|
273
|
+
const real = failures.find(e => e && e.code !== 'MODULE_NOT_FOUND')
|
|
274
|
+
if (real) throw real
|
|
275
|
+
throw new Error(
|
|
276
|
+
'<script lang="ts"> needs a TypeScript transpiler and none is available (Node.js ' + process.version +
|
|
277
|
+
' has no built-in one; that needs >= 22.13). Install one in the project: `vue-live node-setup --with sucrase` ' +
|
|
278
|
+
'(or esbuild, @babel/core + @babel/plugin-transform-typescript, typescript < 7); for this gem\'s own tests run `rake test:setup`.'
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function notFound(what) {
|
|
283
|
+
const e = new Error(what + ' is not available')
|
|
284
|
+
e.code = 'MODULE_NOT_FOUND'
|
|
285
|
+
return e
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function formatError(e) {
|
|
289
|
+
if (!e) return 'unknown error'
|
|
290
|
+
if (typeof e === 'string') return e
|
|
291
|
+
let msg = e.message || String(e)
|
|
292
|
+
if (e.loc && e.loc.start) msg += ` (line ${e.loc.start.line}, column ${e.loc.start.column})`
|
|
293
|
+
return msg
|
|
294
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'io/wait'
|
|
6
|
+
|
|
7
|
+
module VueLive
|
|
8
|
+
module Compiler
|
|
9
|
+
# Backend that drives @vue/compiler-sfc through Node.js (lib/vue_live/compiler/node/compile.js).
|
|
10
|
+
#
|
|
11
|
+
# Templates are compiled to render functions, so the runtime-only Vue build is enough;
|
|
12
|
+
# <script setup>, TypeScript, Pug, Sass/Less/Stylus and CSS modules all work when the matching
|
|
13
|
+
# npm packages are installed in the project.
|
|
14
|
+
#
|
|
15
|
+
# Requests go to a long-lived worker process (one per configuration), so after the first
|
|
16
|
+
# compile each one costs a few milliseconds instead of a Node start-up. Set
|
|
17
|
+
# config.node_worker = false to spawn one process per compile instead.
|
|
18
|
+
class Node < Base
|
|
19
|
+
SCRIPT = File.expand_path('node/compile.js', __dir__)
|
|
20
|
+
REQUEST_TIMEOUT = 60 # seconds; a hung worker is killed and restarted
|
|
21
|
+
|
|
22
|
+
@availability = {}
|
|
23
|
+
@workers = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# true when `node` runs and @vue/compiler-sfc resolves from the project root.
|
|
28
|
+
def available?(config = VueLive.config)
|
|
29
|
+
key = [config.node_bin, config.root]
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
return @availability[key] if @availability.key?(key)
|
|
32
|
+
|
|
33
|
+
@availability[key] = probe(config)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset!
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@availability.clear
|
|
40
|
+
@workers.each_value(&:stop)
|
|
41
|
+
@workers.clear
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The shared worker for +config+ (started on first use).
|
|
46
|
+
def worker_for(config)
|
|
47
|
+
key = [config.node_bin, config.root.to_s]
|
|
48
|
+
@mutex.synchronize { @workers[key] ||= Worker.new(config) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def probe(config)
|
|
54
|
+
out, status = Open3.capture2e(
|
|
55
|
+
config.node_bin, '-e',
|
|
56
|
+
"require.resolve('@vue/compiler-sfc', { paths: [process.argv[1]] })",
|
|
57
|
+
config.root.to_s
|
|
58
|
+
)
|
|
59
|
+
status.success? || (config.logger.debug("[vue_live] Node backend unavailable: #{out.strip}") && false)
|
|
60
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def compile(descriptor, relative_path:, absolute_path: nil)
|
|
66
|
+
scope_id = Compiler.scope_id(relative_path)
|
|
67
|
+
payload = {
|
|
68
|
+
source: descriptor.source,
|
|
69
|
+
filename: relative_path,
|
|
70
|
+
absolutePath: absolute_path,
|
|
71
|
+
scopeId: scope_id,
|
|
72
|
+
isProd: config.production?,
|
|
73
|
+
sourceMap: config.source_maps?
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
data = if config.node_worker
|
|
77
|
+
self.class.worker_for(config).request(payload)
|
|
78
|
+
else
|
|
79
|
+
one_shot(payload)
|
|
80
|
+
end
|
|
81
|
+
check_errors!(data, relative_path)
|
|
82
|
+
|
|
83
|
+
Result.new(code: data['code'].to_s, css: data['css'].to_s, source_map: data['map'],
|
|
84
|
+
scope_id: data['scoped'] ? scope_id : nil, backend: :node,
|
|
85
|
+
dependencies: Array(data['dependencies']))
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
raise CompileError.new("Node.js executable not found: #{config.node_bin}", file: relative_path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def one_shot(payload)
|
|
93
|
+
out, err, status = Open3.capture3(config.node_bin, SCRIPT, config.root.to_s, stdin_data: JSON.generate(payload))
|
|
94
|
+
data = JSON.parse(out)
|
|
95
|
+
if !status.success? && Array(data['errors']).empty?
|
|
96
|
+
data['errors'] =
|
|
97
|
+
[err.strip.empty? ? 'node exited with an error' : err.strip]
|
|
98
|
+
end
|
|
99
|
+
data
|
|
100
|
+
rescue JSON::ParserError
|
|
101
|
+
{ 'errors' => ["unexpected output from compile.js: #{(err.to_s + out.to_s).strip}"] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_errors!(data, relative_path)
|
|
105
|
+
errors = Array(data['errors'])
|
|
106
|
+
raise CompileError.new(errors.join("\n"), file: relative_path) unless errors.empty?
|
|
107
|
+
|
|
108
|
+
Array(data['tips']).each { |tip| config.logger.warn("[vue_live] #{relative_path}: #{tip}") }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A `node compile.js --server` process speaking newline-delimited JSON over stdin/stdout.
|
|
112
|
+
# Requests are serialised with a mutex; the worker is restarted transparently when it dies.
|
|
113
|
+
class Worker
|
|
114
|
+
def initialize(config)
|
|
115
|
+
@config = config
|
|
116
|
+
@mutex = Mutex.new
|
|
117
|
+
@sequence = 0
|
|
118
|
+
@pid = nil
|
|
119
|
+
at_exit { stop }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def request(payload)
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
attempts = 0
|
|
125
|
+
loop do
|
|
126
|
+
attempts += 1
|
|
127
|
+
start unless alive?
|
|
128
|
+
begin
|
|
129
|
+
return exchange(payload)
|
|
130
|
+
rescue WorkerDied => e
|
|
131
|
+
stop
|
|
132
|
+
raise CompileError.new("Node worker failed: #{e.message}", file: payload[:filename]) if attempts >= 2
|
|
133
|
+
|
|
134
|
+
@config.logger.warn("[vue_live] Node worker died (#{e.message}); restarting")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def alive?
|
|
141
|
+
@pid && @wait_thread&.alive?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stop
|
|
145
|
+
return unless @pid
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
@stdin&.close
|
|
149
|
+
rescue IOError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
begin
|
|
153
|
+
Process.kill('TERM', @pid)
|
|
154
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
@wait_thread&.join(2)
|
|
158
|
+
@stdout&.close unless @stdout&.closed?
|
|
159
|
+
@stderr&.close unless @stderr&.closed?
|
|
160
|
+
ensure
|
|
161
|
+
@pid = nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
class WorkerDied < StandardError; end
|
|
167
|
+
|
|
168
|
+
def start
|
|
169
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@config.node_bin, SCRIPT, @config.root.to_s, '--server')
|
|
170
|
+
@pid = @wait_thread.pid
|
|
171
|
+
@stdin.sync = true
|
|
172
|
+
@config.logger.debug("[vue_live] started Node worker pid #{@pid}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def exchange(payload)
|
|
176
|
+
@sequence += 1
|
|
177
|
+
id = @sequence
|
|
178
|
+
begin
|
|
179
|
+
@stdin.puts(JSON.generate(payload.merge(id: id)))
|
|
180
|
+
rescue Errno::EPIPE, IOError => e
|
|
181
|
+
raise WorkerDied, e.message
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
line = read_line_with_timeout
|
|
185
|
+
data = JSON.parse(line)
|
|
186
|
+
raise WorkerDied, "response id mismatch (#{data['id']} != #{id})" if data.key?('id') && data['id'] != id
|
|
187
|
+
|
|
188
|
+
data
|
|
189
|
+
rescue JSON::ParserError => e
|
|
190
|
+
raise WorkerDied, "bad response: #{e.message}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def read_line_with_timeout
|
|
194
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REQUEST_TIMEOUT
|
|
195
|
+
loop do
|
|
196
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
197
|
+
raise WorkerDied, 'timed out waiting for compile.js' if remaining <= 0
|
|
198
|
+
|
|
199
|
+
next unless @stdout.wait_readable(remaining)
|
|
200
|
+
|
|
201
|
+
line = @stdout.gets
|
|
202
|
+
raise WorkerDied, "exited: #{drain_stderr}" if line.nil?
|
|
203
|
+
|
|
204
|
+
return line
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def drain_stderr
|
|
209
|
+
@stderr.read_nonblock(8192).to_s.strip
|
|
210
|
+
rescue IO::WaitReadable, IOError, SystemCallError
|
|
211
|
+
''
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|