diff --git a/lib/propshaft/assembly.rb b/lib/propshaft/assembly.rb index 3fbb11c..c3a889c 100644 --- a/lib/propshaft/assembly.rb +++ b/lib/propshaft/assembly.rb @@ -5,6 +5,7 @@ require "propshaft/processor" require "propshaft/compilers" require "propshaft/compiler/css_asset_urls" +require "propshaft/compiler/js_import_urls" require "propshaft/compiler/source_mapping_urls" class Propshaft::Assembly diff --git a/lib/propshaft/compiler/js_import_urls.rb b/lib/propshaft/compiler/js_import_urls.rb new file mode 100644 index 0000000..fd35ec3 --- /dev/null +++ b/lib/propshaft/compiler/js_import_urls.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "propshaft/compiler" + +class Propshaft::Compiler::JsImportUrls < Propshaft::Compiler + # Sample of syntax captured by regex: + # Import and export declarations: + # import defaultExport, { export1, /* … */ } from "module-name"; + # import defaultExport, * as name from "module-name"; + # import "module-name"; + # export * from "module-name"; + # Dynamic imports: + # import("./file.js").then((module) => ...) + # + # ( # Caputre 1: + # (?:import|export) # Matches import or export + # (?:\s*|.*?from\s*) # Matches any whitespace OR anything followed by "from" followed by any whitespace + # ) + # ( # Caputre 2: + # (?:\(\s*)? # Optionally matches ( followed by any whitespace + # ["'] # Matches " or ' + # ) + # ( # Capture 3: + # (?:\.\/|\.\.\/|\/) # Matches ./ OR ../ OR / + # [^\/] # Matches any character that isn't /. Prevents imports strating with relative url protocol // + # [^"']+ # Matches any characters that aren't " or ' + # ) + # ( # Caputre 4: + # ["'] # Matches " or ' + # (?:\s*\))? # Optionally matches any whitespace followed by a ) + # ) + IMPORT_URL_PATTERN = /((?:import|export)(?:\s*|.*?from\s*))((?:\(\s*)?["'])((?:\.\/|\.\.\/|\/)[^\/][^"']+)(["'](?:\s*\))?)/m + + def compile(logical_path, input) + input.gsub(IMPORT_URL_PATTERN) { asset_url resolve_path(logical_path.dirname, $3), logical_path, $3, $1, $2, $4 } + end + + private + def resolve_path(directory, filename) + if filename.start_with?("../") + Pathname.new(directory + filename).relative_path_from("").to_s + elsif filename.start_with?("/") + filename.delete_prefix("/").to_s + else + (directory + filename.delete_prefix("./")).to_s + end + end + + def asset_url(resolved_path, logical_path, pattern, import, open, close) + if asset = assembly.load_path.find(resolved_path) + %[#{import}#{open}#{url_prefix}/#{asset.digested_path}#{close}] + else + Propshaft.logger.warn "Unable to resolve '#{pattern}' for missing asset '#{resolved_path}' in #{logical_path}" + %[#{import}#{open}#{pattern}#{close}] + end + end +end diff --git a/lib/propshaft/railtie.rb b/lib/propshaft/railtie.rb index ad8d769..7946ec0 100644 --- a/lib/propshaft/railtie.rb +++ b/lib/propshaft/railtie.rb @@ -13,7 +13,8 @@ class Railtie < ::Rails::Railtie config.assets.compilers = [ [ "text/css", Propshaft::Compiler::CssAssetUrls ], [ "text/css", Propshaft::Compiler::SourceMappingUrls ], - [ "text/javascript", Propshaft::Compiler::SourceMappingUrls ] + [ "text/javascript", Propshaft::Compiler::SourceMappingUrls ], + [ "text/javascript", Propshaft::Compiler::JsImportUrls ] ] config.assets.sweep_cache = Rails.env.development? config.assets.server = Rails.env.development? || Rails.env.test? diff --git a/test/fixtures/assets/vendor/file.js b/test/fixtures/assets/vendor/file.js new file mode 100644 index 0000000..9bea59b --- /dev/null +++ b/test/fixtures/assets/vendor/file.js @@ -0,0 +1 @@ +console.log("foobar") diff --git a/test/fixtures/assets/vendor/foobar/file.js b/test/fixtures/assets/vendor/foobar/file.js new file mode 100644 index 0000000..28f27a1 --- /dev/null +++ b/test/fixtures/assets/vendor/foobar/file.js @@ -0,0 +1 @@ +console.log("world") diff --git a/test/fixtures/assets/vendor/foobar/source/file.js b/test/fixtures/assets/vendor/foobar/source/file.js new file mode 100644 index 0000000..7728117 --- /dev/null +++ b/test/fixtures/assets/vendor/foobar/source/file.js @@ -0,0 +1 @@ +console.log("hello") diff --git a/test/propshaft/compiler/js_import_urls_test.rb b/test/propshaft/compiler/js_import_urls_test.rb new file mode 100644 index 0000000..5a1fcd8 --- /dev/null +++ b/test/propshaft/compiler/js_import_urls_test.rb @@ -0,0 +1,143 @@ +require "test_helper" +require "minitest/mock" +require "propshaft/asset" +require "propshaft/assembly" +require "propshaft/compilers" + +class Propshaft::Compiler::JsImportUrlsTest < ActiveSupport::TestCase + setup do + @options = ActiveSupport::OrderedOptions.new.tap { |config| + config.paths = [ Pathname.new("#{__dir__}/../../fixtures/assets/vendor") ] + config.output_path = Pathname.new("#{__dir__}/../../fixtures/output") + config.prefix = "/assets" + } + end + + test "basic relative imports and exports to file in same folder" do + compiled = compile_asset_with_content(%(import "./file.js")) + assert_match(/import "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "./file.js")) + assert_match(/import \* from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "./file.js")) + assert_match(/export \* from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "basic relative imports and exports to file with single quotes" do + compiled = compile_asset_with_content(%(import './file.js')) + assert_match(/import '\/assets\/foobar\/source\/file-[a-z0-9]{8}.js'/, compiled) + compiled = compile_asset_with_content(%(import * from './file.js')) + assert_match(/import \* from '\/assets\/foobar\/source\/file-[a-z0-9]{8}.js'/, compiled) + compiled = compile_asset_with_content(%(export * from './file.js')) + assert_match(/export \* from '\/assets\/foobar\/source\/file-[a-z0-9]{8}.js'/, compiled) + end + + test "multiline imports and exports to file in same folder" do + compiled = compile_asset_with_content("import {\na as b\n} from \"./file.js\"") + assert_match(/import {\na as b\n} from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/m, compiled) + compiled = compile_asset_with_content("export {\na as b\n} from \"./file.js\"") + assert_match(/export {\na as b\n} from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/m, compiled) + end + + test "imports and exports with excess space to file in same folder" do + compiled = compile_asset_with_content(%(import "./file.js")) + assert_match(/import "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "./file.js")) + assert_match(/import \* from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "./file.js")) + assert_match(/export \* from "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "basic relative imports and exports to file in same parent" do + compiled = compile_asset_with_content(%(import "../file.js")) + assert_match(/import "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "../file.js")) + assert_match(/import \* from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "../file.js")) + assert_match(/export \* from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "multiline imports and exports to file in same parent" do + compiled = compile_asset_with_content("import {\na as b\n} from \"../file.js\"") + assert_match(/import {\na as b\n} from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/m, compiled) + compiled = compile_asset_with_content("export {\na as b\n} from \"../file.js\"") + assert_match(/export {\na as b\n} from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/m, compiled) + end + + test "imports and exports with excess space to file in same parent" do + compiled = compile_asset_with_content(%(import "../file.js")) + assert_match(/import "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "../file.js")) + assert_match(/import \* from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "../file.js")) + assert_match(/export \* from "\/assets\/foobar\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "basic relative imports and exports to file in the root" do + compiled = compile_asset_with_content(%(import "/file.js")) + assert_match(/import "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "/file.js")) + assert_match(/import \* from "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "/file.js")) + assert_match(/export \* from "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "multiline imports and exports to file in the root" do + compiled = compile_asset_with_content("import {\na as b\n} from \"/file.js\"") + assert_match(/import {\na as b\n} from "\/assets\/file-[a-z0-9]{8}.js"/m, compiled) + compiled = compile_asset_with_content("export {\na as b\n} from \"/file.js\"") + assert_match(/export {\na as b\n} from "\/assets\/file-[a-z0-9]{8}.js"/m, compiled) + end + + test "imports and exports with excess space to file in the root" do + compiled = compile_asset_with_content(%(import "/file.js")) + assert_match(/import "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "/file.js")) + assert_match(/import \* from "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "/file.js")) + assert_match(/export \* from "\/assets\/file-[a-z0-9]{8}.js"/, compiled) + end + + test "basic relative dynamic imports to file in same folder" do + compiled = compile_asset_with_content(%(import("./file.js").then())) + assert_match(/import\("\/assets\/foobar\/source\/file-[a-z0-9]{8}.js"\).then\(\)/, compiled) + end + + test "basic relative dynamic imports to file with single quotes" do + compiled = compile_asset_with_content(%(import('./file.js'))) + assert_match(/import\('\/assets\/foobar\/source\/file-[a-z0-9]{8}.js'\)/, compiled) + end + + test "dynamic imports with excess space to file in same folder" do + compiled = compile_asset_with_content(%(import \( "./file.js" \) )) + assert_match(/import \( "\/assets\/foobar\/source\/file-[a-z0-9]{8}.js" \)/, compiled) + end + + test "missing asset" do + compiled = compile_asset_with_content(%(import "./nothere.js")) + assert_match(/import "\.\/nothere.js"/, compiled) + compiled = compile_asset_with_content(%(import * from "./nothere.js")) + assert_match(/import \* from "\.\/nothere.js"/, compiled) + compiled = compile_asset_with_content(%(export * from "./nothere.js")) + assert_match(/export \* from "\.\/nothere.js"/, compiled) + compiled = compile_asset_with_content(%(import("./nothere.js").then())) + assert_match(/import\("\.\/nothere.js"\).then\(\)/, compiled) + end + + test "relative protocol url" do + compiled = compile_asset_with_content(%(import "//rubyonrails.org/assets/main.js")) + assert_match(/import "\/\/rubyonrails\.org\/assets\/main\.js"/, compiled) + end + + private + def compile_asset_with_content(content) + root_path = Pathname.new("#{__dir__}/../../fixtures/assets/vendor") + logical_path = "foobar/source/test.js" + + asset = Propshaft::Asset.new(root_path.join(logical_path), logical_path: logical_path) + asset.stub :content, content do + assembly = Propshaft::Assembly.new(@options) + assembly.compilers.register "text/javascript", Propshaft::Compiler::JsImportUrls + assembly.compilers.compile(asset) + end + end +end