diff --git a/Rakefile b/Rakefile index d69d8900..dbadc2b8 100644 --- a/Rakefile +++ b/Rakefile @@ -19,7 +19,7 @@ namespace :demo do end namespace :packages do - # TODO: add tests and support for Vite and esbuild + # TODO: add tests and support for Vite desc "Build & test the Node version of Ruby2JS plus frontend bundling packages" task :test do @@ -30,9 +30,7 @@ namespace :packages do end Dir.chdir 'packages/esbuild-plugin' do - npm_root = `npm root`.strip sh 'yarn install' unless File.exist? 'yarn.lock' - sh "cp ../ruby2js/ruby2js.js #{npm_root}/@ruby2js/ruby2js/ruby2js.js" sh 'yarn test' end diff --git a/demo/ruby2js.rb b/demo/ruby2js.rb index 5241117f..a1cb36dd 100755 --- a/demo/ruby2js.rb +++ b/demo/ruby2js.rb @@ -23,6 +23,7 @@ require 'ruby2js/demo' require 'cgi' require 'pathname' +require 'json' def parse_request(env=ENV) @@ -57,6 +58,10 @@ def parse_request(env=ENV) opts.on('--preset', "use sane defaults (modern eslevel & common filters)") {options[:preset] = true} + opts.on('-C', '--config [FILE]', "configuration file to use (default is config/ruby2js.rb)") {|filename| + options[:config_file] = filename + } + opts.on('--autoexports [default]', "add export statements for top level constants") {|option| options[:autoexports] = option ? option.to_sym : true } @@ -90,6 +95,10 @@ def parse_request(env=ENV) selected.push(*names) end + opts.on('--filepath [PATH]', "supply a path if stdin is related to a source file") do |filepath| + options[:file] = filepath + end + opts.on('--identity', "triple equal comparison operators") {options[:comparison] = :identity} opts.on('--import_from_skypack', "use Skypack for internal functions import statements") do @@ -130,6 +139,10 @@ def parse_request(env=ENV) options[:underscored_private] = true end + opts.on("--sourcemap", "Provide a JSON object with the code and sourcemap") do + @provide_sourcemap = true + end + # shameless hack. Instead of repeating the available options, extract them # from the OptionParser. Exclude default options and es20xx options. options_available = opts.instance_variable_get(:@stack).last.list. @@ -168,9 +181,29 @@ def parse_request(env=ENV) # command line support if ARGV.length > 0 options[:file] = ARGV.first - puts Ruby2JS.convert(File.read(ARGV.first), options).to_s + conv = Ruby2JS.convert(File.read(ARGV.first), options) + if @provide_sourcemap + puts( + { + code: conv.to_s, + sourcemap: conv.sourcemap, + }.to_json + ) + else + puts conv.to_s + end else - puts Ruby2JS.convert($stdin.read, options).to_s + conv = Ruby2JS.convert($stdin.read, options) + if @provide_sourcemap + puts( + { + code: conv.to_s, + sourcemap: conv.sourcemap, + }.to_json + ) + else + puts conv.to_s + end end else diff --git a/lib/ruby2js.rb b/lib/ruby2js.rb index 04a11271..f672bd39 100644 --- a/lib/ruby2js.rb +++ b/lib/ruby2js.rb @@ -8,6 +8,7 @@ $VERBOSE = old_verbose end +require 'ruby2js/configuration_dsl' unless RUBY_ENGINE == 'opal' require 'ruby2js/converter' require 'ruby2js/filter' require 'ruby2js/namespace' @@ -68,16 +69,11 @@ class Processor < Parser::AST::Processor include Ruby2JS::Filter BINARY_OPERATORS = Converter::OPERATORS[2..-1].flatten - attr_accessor :prepend_list, :disable_autoimports, :namespace + attr_accessor :prepend_list, :disable_autoimports, :disable_autoexports, :namespace def initialize(comments) @comments = comments - # check if magic comment is present: - first_comment = @comments.values.first&.map(&:text)&.first - @disable_autoimports = first_comment&.include?(" autoimports: false") - @disable_autoexports = first_comment&.include?(" autoexports: false") - @ast = nil @exclude_methods = [] @prepend_list = Set.new @@ -218,18 +214,10 @@ def on_send(node) end end + # TODO: this method has gotten long and unwieldy! def self.convert(source, options={}) Filter.autoregister unless RUBY_ENGINE == 'opal' options = options.dup - if options[:preset] - options[:eslevel] ||= @@eslevel_preset_default - options[:filters] = Filter::PRESET_FILTERS + Array(options[:filters]).uniq - options[:comparison] ||= :identity - options[:underscored_private] = true unless options[:underscored_private] == false - end - options[:eslevel] ||= @@eslevel_default - options[:strict] = @@strict_default if options[:strict] == nil - options[:module] ||= @@module_default || :esm if Proc === source file,line = source.source_location @@ -246,11 +234,54 @@ def self.convert(source, options={}) comments = ast ? Parser::Source::Comment.associate(ast, comments) : {} end + # check if magic comment is present + first_comment = comments.values.first&.map(&:text)&.first + if first_comment + if first_comment.include?(" ruby2js: preset") + options[:preset] = true + if first_comment.include?("filters: ") + options[:filters] = first_comment.match(%r(filters:\s*?([^\s]+)\s?.*$))[1].split(",").map(&:to_sym) + end + if first_comment.include?("eslevel: ") + options[:eslevel] = first_comment.match(%r(eslevel:\s*?([^\s]+)\s?.*$))[1].to_i + end + if first_comment.include?("disable_filters: ") + options[:disable_filters] = first_comment.match(%r(disable_filters:\s*?([^\s]+)\s?.*$))[1].split(",").map(&:to_sym) + end + end + disable_autoimports = first_comment.include?(" autoimports: false") + disable_autoexports = first_comment.include?(" autoexports: false") + end + + unless RUBY_ENGINE == 'opal' + unless options.key?(:config_file) || !File.exist?("config/ruby2js.rb") + options[:config_file] ||= "config/ruby2js.rb" + end + + if options[:config_file] + options = ConfigurationDSL.load_from_file(options[:config_file], options).to_h + end + end + + if options[:preset] + options[:eslevel] ||= @@eslevel_preset_default + options[:filters] = Filter::PRESET_FILTERS + Array(options[:filters]).uniq + if options[:disable_filters] + options[:filters] -= options[:disable_filters] + end + options[:comparison] ||= :identity + options[:underscored_private] = true unless options[:underscored_private] == false + end + options[:eslevel] ||= @@eslevel_default + options[:strict] = @@strict_default if options[:strict] == nil + options[:module] ||= @@module_default || :esm + namespace = Namespace.new filters = Filter.require_filters(options[:filters] || Filter::DEFAULTS) unless filters.empty? + filter_options = options.merge({ filters: filters }) filters.dup.each do |filter| filters = filter.reorder(filters) if filter.respond_to? :reorder end @@ -261,7 +292,9 @@ def self.convert(source, options={}) end filter = filter.new(comments) - filter.options = options + filter.disable_autoimports = disable_autoimports + filter.disable_autoexports = disable_autoexports + filter.options = filter_options filter.namespace = namespace ast = filter.process(ast) diff --git a/lib/ruby2js/configuration_dsl.rb b/lib/ruby2js/configuration_dsl.rb new file mode 100644 index 00000000..e1a0c7a2 --- /dev/null +++ b/lib/ruby2js/configuration_dsl.rb @@ -0,0 +1,86 @@ +module Ruby2JS + class ConfigurationDSL + def self.load_from_file(config_file, options = {}) + new(options).tap { _1.instance_eval(File.read(config_file), config_file, 1) } + end + + def initialize(options = {}) + @options = options + end + + def preset(bool = true) + @options[:preset] = bool + end + + def filter(name) + @options[:filters] ||= [] + @options[:filters] << name + end + + def remove_filter(name) + @options[:filters]&.reject! { _1 == name } + end + + def eslevel(level) + @options[:eslevel] = level + end + + def equality_comparison + @options[:comparison] = :equality + end + + def identity_comparison + @options[:comparison] = :identity + end + + def esm_modules + @options[:module] = :esm + end + + def cjs_modules + @options[:module] = :cjs + end + + def underscored_ivars + @options[:underscored_private] = true + end + + # Only applies for ES2022+ + def private_field_ivars + @options[:underscored_private] = false + end + + def logical_or + @options[:or] = :logical + end + + def nullish_or + @options[:or] = :nullish + end + + def use_strict(bool = true) + @options[:strict] = bool + end + + def autoimport(identifier = nil, file = nil, &block) + if block + @options[:autoimports] = block + return + elsif @options[:autoimports].is_a?(Proc) + @options[:autoimports] = {} + end + + @options[:autoimports] ||= {} + @options[:autoimports][identifier] = file + end + + def include_method(method_name) + @options[:include] ||= [] + @options[:include] << method_name unless @options[:include].include?(method_name) + end + + def to_h + @options + end + end +end diff --git a/lib/ruby2js/converter/return.rb b/lib/ruby2js/converter/return.rb index edf9a9d5..0589868b 100644 --- a/lib/ruby2js/converter/return.rb +++ b/lib/ruby2js/converter/return.rb @@ -15,7 +15,7 @@ class Converter EXPRESSIONS = [ :array, :float, :hash, :int, :lvar, :nil, :send, :attr, :str, :sym, :dstr, :dsym, :cvar, :ivar, :zsuper, :super, :or, :and, :block, :const, :true, :false, :xnode, :taglit, :self, - :op_asgn, :and_asgn, :or_asgn, :taglit, :gvar, :csend ] + :op_asgn, :and_asgn, :or_asgn, :taglit, :gvar, :csend, :call ] handle :autoreturn do |*statements| return if statements == [nil] diff --git a/lib/ruby2js/filter/camelCase.rb b/lib/ruby2js/filter/camelCase.rb index 926c5ec8..a7ff72ca 100644 --- a/lib/ruby2js/filter/camelCase.rb +++ b/lib/ruby2js/filter/camelCase.rb @@ -132,6 +132,10 @@ def on_sym(node) handle_generic_node(super, :sym) end + def on_assign(node) + S(:assign , node.children[0], *node.children[1..-1].map{ process _1 }) + end + def on_defs(node) node = super return node if node.type != :defs diff --git a/lib/ruby2js/filter/require.rb b/lib/ruby2js/filter/require.rb index 6d1214be..a9dd72ed 100644 --- a/lib/ruby2js/filter/require.rb +++ b/lib/ruby2js/filter/require.rb @@ -91,6 +91,18 @@ def on_send(node) target << child.children[1] elsif child.type == :def target << child.children[0] + elsif child.type == :send && child.children[1] == :async + target << child.children[2].children[0] + elsif child.type == :const + target << child.children[1] + elsif child.type == :array + child.children.each do |export_statement| + if export_statement.type == :const + target << export_statement.children[1] + elsif export_statement.type == :hash + default_exports << export_statement.children[0].children[1].children[1] + end + end end end @@ -99,6 +111,12 @@ def on_send(node) else named_exports += auto_exports end + default_exports.map! { _1.to_s.sub(/[?!]/, '').then do |name| + respond_to?(:camelCase) ? camelCase(name) : name.to_sym + end } + named_exports.map! { _1.to_s.sub(/[?!]/, '').then do |name| + respond_to?(:camelCase) ? camelCase(name) : name.to_sym + end } imports = @require_seen[realpath] imports << s(:const, nil, default_exports.first) unless default_exports.empty? diff --git a/packages/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json index edaa59db..464c9328 100644 --- a/packages/esbuild-plugin/package.json +++ b/packages/esbuild-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@ruby2js/esbuild-plugin", - "version": "0.0.3", + "version": "1.0.0", "description": "ruby2js plugin for esbuild", "contributors": [ "Jared White", @@ -28,7 +28,6 @@ "access": "public" }, "dependencies": { - "@ruby2js/ruby2js": ">0.0.1", "convert-source-map": "^1.8.0" }, "devDependencies": { diff --git a/packages/esbuild-plugin/src/index.js b/packages/esbuild-plugin/src/index.js index 8db22802..c3be01a9 100644 --- a/packages/esbuild-plugin/src/index.js +++ b/packages/esbuild-plugin/src/index.js @@ -1,26 +1,70 @@ -const Ruby2JS = require('@ruby2js/ruby2js') +const path = require("path") const convert = require('convert-source-map') -const path = require('path') const fs = require('fs').promises +const { spawn } = require('child_process'); -module.exports = (options = {}) => ({ +const spawnChild = async (source, extraArgs, filepath) => { + const child = spawn('bundle', ['exec', 'ruby2js', '--filepath', filepath, ...extraArgs]) + + child.stdin.write(source) + child.stdin.end() + + let data = ""; + for await (const chunk of child.stdout) { + data += chunk; + } + let error = ""; + for await (const chunk of child.stderr) { + error += chunk; + } + const exitCode = await new Promise((resolve, reject) => { + child.on('close', resolve); + }); + + if (exitCode) { + throw new Error(`subprocess error exit ${exitCode}, ${data} ${error}`); + } + return data; +} + +const ruby2js = (options = {}) => ({ name: 'ruby2js', setup(build) { if (!options.buildFilter) options.buildFilter = /\.js\.rb$/ + let extraArgs = [] + if (typeof options.provideSourceMaps === "undefined") { + options.provideSourceMaps = true + } + if (options.provideSourceMaps) { + extraArgs.push("--sourcemap") + } + if (typeof options.extraArgs !== undefined) { + extraArgs = [...extraArgs, ...(options.extraArgs || [])] + } build.onLoad({ filter: options.buildFilter }, async (args) => { const code = await fs.readFile(args.path, 'utf8') - js = Ruby2JS.convert(code, { ...options, file: args.path }) - const output = js.toString() + let js = await spawnChild(code, extraArgs, args.path) - const smap = js.sourcemap - smap.sourcesContent = [code] - smap.sources[0] = path.basename(args.path) + if (options.provideSourceMaps) { + js = JSON.parse(js) + const output = `${js.code}\n` + const smap = js.sourcemap + smap.sourcesContent = [code] + smap.sources[0] = path.basename(args.path) - return { - contents: output + convert.fromObject(smap).toComment(), - loader: 'js' + return { + contents: output + convert.fromObject(smap).toComment(), + loader: 'js' + } + } else { + return { + contents: js, + loader: 'js' + } } }) }, }) + +module.exports = ruby2js diff --git a/packages/esbuild-plugin/test/esbuild.config.js b/packages/esbuild-plugin/test/esbuild.config.js index 95a565df..250514dd 100644 --- a/packages/esbuild-plugin/test/esbuild.config.js +++ b/packages/esbuild-plugin/test/esbuild.config.js @@ -16,7 +16,7 @@ require("esbuild").build({ minify, plugins: [ ruby2js({ - preset: true + extraArgs: ["--preset"] }) ], -}).catch(() => process.exit(1)) \ No newline at end of file +}).catch(() => process.exit(1)) diff --git a/packages/esbuild-plugin/test/test_esbuild.js b/packages/esbuild-plugin/test/test_esbuild.js index 956384ed..54a7d37e 100644 --- a/packages/esbuild-plugin/test/test_esbuild.js +++ b/packages/esbuild-plugin/test/test_esbuild.js @@ -1,24 +1,28 @@ const assert = require('assert') const fs = require('fs').promises -describe('@ruby2js/esbuild-plugin', function() { +function timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +describe('@ruby2js/esbuild-plugin', function () { this.timeout(5000); - it('runs code through ruby2js', () => { + it('runs code through ruby2js', async () => { require("./esbuild.config.js") - setTimeout(async () => { - const code = await fs.readFile( - "app/assets/builds/application.js", - { encoding: "utf-8"} - ) + await timeout(1000) + + const code = await fs.readFile( + "app/assets/builds/application.js", + { encoding: "utf-8" } + ) - assert.strictEqual( + assert.strictEqual( `(() => { - // main.js.rb - console.log(parseInt("2A", 16)); - })(); - `, code) - }, 1000) + // main.js.rb + console.log(parseInt("2A", 16)); +})(); +`, code) }) }) diff --git a/spec/config/test_ruby2js.rb b/spec/config/test_ruby2js.rb new file mode 100644 index 00000000..65bf33d0 --- /dev/null +++ b/spec/config/test_ruby2js.rb @@ -0,0 +1,17 @@ +preset + +filter :camelCase +filter :lit + +eslevel 2022 + +equality_comparison + +nullish_or + +use_strict true + +autoimport :FooBar, "@org/package/foobar.js" + +include_method :class +include_method :call diff --git a/spec/configuration_dsl_spec.rb b/spec/configuration_dsl_spec.rb new file mode 100644 index 00000000..48ad96ab --- /dev/null +++ b/spec/configuration_dsl_spec.rb @@ -0,0 +1,23 @@ +gem 'minitest' +require 'minitest/autorun' +require 'ruby2js' + +describe Ruby2JS::ConfigurationDSL do + + def to_js( string) + _(Ruby2JS.convert(string, config_file: "spec/config/test_ruby2js.rb").to_s) + end + + # random tests just to santity check…see return_spec.rb for the full suite + describe "loaded config file" do + it "should affect the transpilation" do + to_js( 'class C; def self.f_n(x_y); FooBar.(x_y); end; def inst; self.class.f_n(); end; end' ). + must_equal '"use strict"; import FooBar from "@org/package/foobar.js"; class C {static fN(xY) {return FooBar(xY)}; get inst() {return this.constructor.fN()}}' + end + + it "should support Lit" do + to_js( 'class FooElement < LitElement; customElement "foo-bar"; end' ). + must_equal '"use strict"; import { LitElement, css, html } from "lit"; class FooElement extends LitElement {}; customElements.define("foo-bar", FooElement)' + end + end +end diff --git a/spec/lit_spec.rb b/spec/lit_spec.rb index 05280653..03148a97 100644 --- a/spec/lit_spec.rb +++ b/spec/lit_spec.rb @@ -15,8 +15,7 @@ def to_js22(string) end def to_js_esm(string) - _(Ruby2JS.convert(string, eslevel: 2021, - filters: [Ruby2JS::Filter::Lit, Ruby2JS::Filter::ESM]).to_s) + _(Ruby2JS.convert(string, eslevel: 2021, filters: [:lit, :esm]).to_s) end describe "properties <= 2021" do diff --git a/spec/preset_spec.rb b/spec/preset_spec.rb index ab8b2349..4f499df1 100644 --- a/spec/preset_spec.rb +++ b/spec/preset_spec.rb @@ -4,10 +4,14 @@ describe "preset option" do - def to_js( string) + def to_js(string) _(Ruby2JS.convert(string, preset: true).to_s) end + def to_js_basic(string) + _(Ruby2JS.convert(string).to_s) + end + # random tests just to santity check…see return_spec.rb for the full suite describe :return do it "should handle arrays" do @@ -43,4 +47,26 @@ def to_js( string) must_equal 'class A {b() {this._c = 1; return this._c}}' end end + + describe :magic_comments do + it 'should allow preset option' do + to_js_basic( %(# ruby2js: preset\nclass A; def b(); @c = 1; end; end;) ). + must_equal %(// ruby2js: preset\nclass A {\n b() {\n this._c = 1;\n return this._c\n }\n}) + end + + it 'should allow filters' do + to_js_basic( %(# ruby2js: preset, filters: camelCase\nclass A; def b_x(); @c_z = 1; end; end;) ). + must_equal %(// ruby2js: preset, filters: camelCase\nclass A {\n bX() {\n this._cZ = 1;\n return this._cZ\n }\n}) + end + + it 'should allow eslevel' do + to_js_basic( %(# ruby2js: preset, eslevel: 2022\nx.last) ). + must_equal %(// ruby2js: preset, eslevel: 2022\nx.at(-1)) + end + + it 'should allow for disabling filters' do + to_js_basic( %(# ruby2js: preset, disable_filters: return\nclass A; def b(); @c = 1; end; end;) ). + must_equal %(// ruby2js: preset, disable_filters: return\nclass A {\n b() {\n this._c = 1\n }\n}) + end + end end diff --git a/spec/require/test4.rb b/spec/require/test4.rb index 766c10fa..d3e11c61 100644 --- a/spec/require/test4.rb +++ b/spec/require/test4.rb @@ -1 +1,4 @@ -export Foo = 1 +Foo = 1 +Whoa = 2 + +export [ Foo, default: Whoa ] diff --git a/spec/require_spec.rb b/spec/require_spec.rb index bee35775..5bcbc099 100644 --- a/spec/require_spec.rb +++ b/spec/require_spec.rb @@ -77,7 +77,7 @@ def to_js_esm_recursive(string) describe :esmimport do it "should handle explicit exports" do to_js_esm( 'require "require/test4.rb"' ). - must_equal 'import { Foo } from "./require/test4.rb"' + must_equal 'import Whoa, { Foo } from "./require/test4.rb"' end it "should handle auto exports" do