From 0ac794eab13e482ea2de3448759df2e141267ab2 Mon Sep 17 00:00:00 2001
From: Takashi Kato <tohosaku@users.osdn.me>
Date: Thu, 10 Nov 2022 22:40:49 +0000
Subject: [PATCH 2/4] Support importmap in plugins

---
 config/initializers/30-redmine.rb             |  4 +-
 lib/generators/redmine_plugin/USAGE           |  1 +
 .../redmine_plugin_generator.rb               |  1 +
 .../redmine_plugin/templates/importmap.rb     |  7 ++
 lib/redmine/asset_path.rb                     |  1 +
 lib/redmine/importmap.rb                      | 87 +++++++++++++++++++
 lib/redmine/plugin_loader.rb                  | 15 ++++
 .../javascript/controllers/bar_controller.js  |  7 ++
 .../foo_plugin/app/javascript/locales/en.js   |  1 +
 .../foo_plugin/app/javascript/locales/ja.js   |  1 +
 .../plugins/foo_plugin/config/importmap.rb    |  7 ++
 test/test_helper.rb                           |  6 ++
 test/unit/lib/redmine/plugin_loader_test.rb   | 35 +++++++-
 13 files changed, 169 insertions(+), 4 deletions(-)
 create mode 100644 lib/generators/redmine_plugin/templates/importmap.rb
 create mode 100644 lib/redmine/importmap.rb
 create mode 100644 test/fixtures/plugins/foo_plugin/app/javascript/controllers/bar_controller.js
 create mode 100644 test/fixtures/plugins/foo_plugin/app/javascript/locales/en.js
 create mode 100644 test/fixtures/plugins/foo_plugin/app/javascript/locales/ja.js
 create mode 100644 test/fixtures/plugins/foo_plugin/config/importmap.rb

diff --git a/config/initializers/30-redmine.rb b/config/initializers/30-redmine.rb
index fba7d511bee..90af0d94c1c 100644
--- a/config/initializers/30-redmine.rb
+++ b/config/initializers/30-redmine.rb
@@ -125,11 +125,13 @@ Rails.application.config.to_prepare do
   end
 end
 
-# Automatically execute asset precompilation on startup in case of changes have been detected in assets
 Rails.application.config.after_initialize do |app|
+  # Automatically execute asset precompilation on startup in case of changes have been detected in assets
   if app.config.assets.redmine_detect_update && app.assets.needs_precompile?
     app.assets.processor.process
   end
+
+  Redmine::Plugin.loader.directories.each(&:draw_importmap)
 end
 
 Rails.application.deprecators[:redmine] = ActiveSupport::Deprecation.new('7.0', 'Redmine')
diff --git a/lib/generators/redmine_plugin/USAGE b/lib/generators/redmine_plugin/USAGE
index b06adca9c1d..60dd95054ab 100644
--- a/lib/generators/redmine_plugin/USAGE
+++ b/lib/generators/redmine_plugin/USAGE
@@ -22,5 +22,6 @@ Example:
       create  plugins/meetings/README.rdoc
       create  plugins/meetings/init.rb
       create  plugins/meetings/config/routes.rb
+      create  plugins/meetings/config/importmap.rb
       create  plugins/meetings/config/locales/en.yml
       create  plugins/meetings/test/test_helper.rb
diff --git a/lib/generators/redmine_plugin/redmine_plugin_generator.rb b/lib/generators/redmine_plugin/redmine_plugin_generator.rb
index d16cd976abe..94adf67f8a8 100644
--- a/lib/generators/redmine_plugin/redmine_plugin_generator.rb
+++ b/lib/generators/redmine_plugin/redmine_plugin_generator.rb
@@ -51,6 +51,7 @@ class RedminePluginGenerator < Rails::Generators::NamedBase
     template 'README.rdoc',    "#{plugin_path}/README.rdoc"
     template 'init.rb.erb',   "#{plugin_path}/init.rb"
     template 'routes.rb',    "#{plugin_path}/config/routes.rb"
+    template 'importmap.rb',    "#{plugin_path}/config/importmap.rb"
     template 'en_rails_i18n.yml',    "#{plugin_path}/config/locales/en.yml"
     template 'test_helper.rb.erb',    "#{plugin_path}/test/test_helper.rb"
   end
diff --git a/lib/generators/redmine_plugin/templates/importmap.rb b/lib/generators/redmine_plugin/templates/importmap.rb
new file mode 100644
index 00000000000..3dbc6169597
--- /dev/null
+++ b/lib/generators/redmine_plugin/templates/importmap.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# pin "foo"
+
+# pin_all_from "app/javascript/locales", under: "locales"
+
+# pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/lib/redmine/asset_path.rb b/lib/redmine/asset_path.rb
index 599c479e8b2..c0c90959bc2 100644
--- a/lib/redmine/asset_path.rb
+++ b/lib/redmine/asset_path.rb
@@ -188,6 +188,7 @@ module Redmine
 
     def clear_cache
       @transition_map = nil
+      @cached_assets_by_path = nil
       super
     end
   end
diff --git a/lib/redmine/importmap.rb b/lib/redmine/importmap.rb
new file mode 100644
index 00000000000..dd5717a18bf
--- /dev/null
+++ b/lib/redmine/importmap.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  module Importmap
+    def draw_importmap
+      return unless has_importmap?
+
+      begin
+        instance_eval(importmap_path.read, importmap_path.to_s)
+      rescue => e
+        Rails.logger.warn "Importmap Error Occured. #{e}"
+      end
+    end
+
+    def has_importmap?
+      importmap_path.present?
+    end
+
+    def importmap_path
+      path = File.join(full_path, 'config/importmap.rb')
+
+      if File.exist? path
+        Pathname.new(path)
+      else
+        nil
+      end
+    end
+
+    # with foo plugin
+    #
+    # pin 'bar'
+    #   => import { exampleFunction } from 'plugin_assets/foo/bar'
+    #
+    # with baz theme
+    #
+    # pin 'qux'
+    #   => import { exampleFunction } from 'themes/baz/qux'
+    def pin(name, to: nil, preload: true)
+      modified_name = File.join(asset_prefix, name)
+      to ||= name + '.js'
+      asset_name    = File.join(asset_prefix, to)
+
+      importmap.pin modified_name, to: asset_name, preload: preload
+    end
+
+    # with foo plugin
+    # pin_all_from 'app/javascript/src', under: 'src'
+    #   => import { exampleFunction } from 'plugin_assets/foo/src/example_module'
+    #
+    # with baz theme
+    # pin_all_from 'javascripts/src', under: 'src'
+    #   => import { exampleFunction } from 'themes/baz/src/example_module'
+    #
+    # The path name of the stimulus controller is not changed.
+    def pin_all_from(directory, under: nil, to: nil, preload: true)
+      extension_dir   = Pathname.new(full_path).relative_path_from(Rails.root).join(directory).to_s
+      extension_under = under === 'controllers' ? 'controllers'
+                                                : under ? File.join(asset_prefix, under)
+                                                        : nil
+      extension_to    = to ? to : File.join(asset_prefix, under)
+      importmap.pin_all_from extension_dir, under: extension_under, to: extension_to, preload: preload
+    end
+
+    private
+
+    def importmap
+      Rails.application.importmap
+    end
+  end
+end
diff --git a/lib/redmine/plugin_loader.rb b/lib/redmine/plugin_loader.rb
index da37e56b41b..2dcee2b3908 100644
--- a/lib/redmine/plugin_loader.rb
+++ b/lib/redmine/plugin_loader.rb
@@ -17,9 +17,12 @@
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
+require 'redmine/importmap'
+
 module Redmine
   class PluginPath
     attr_reader :assets_dir, :initializer, :prefix
+    include Importmap
 
     def initialize(dir, prefix)
       @dir = dir
@@ -35,6 +38,7 @@ module Redmine
     def to_s
       @dir
     end
+    alias_method :full_path, :to_s
 
     def has_assets_dir?
       File.directory?(@assets_dir)
@@ -44,12 +48,23 @@ module Redmine
       File.file?(@initializer)
     end
 
+    def asset_prefix
+      "#{@prefix}/#{File.basename(@dir)}"
+    end
+
     def base_dir
       @base_dir ||= Pathname.new(assets_dir)
     end
 
     def asset_paths
       paths = base_dir.children.select(&:directory?)
+      if has_importmap?
+        plugin_root = Pathname.new(full_path)
+        ['app/javascript', 'vendor/javascript'].each do |dir|
+          jsdir = plugin_root.join(dir)
+          paths << jsdir if jsdir.exist?
+        end
+      end
       paths
     end
   end
diff --git a/test/fixtures/plugins/foo_plugin/app/javascript/controllers/bar_controller.js b/test/fixtures/plugins/foo_plugin/app/javascript/controllers/bar_controller.js
new file mode 100644
index 00000000000..fa042f990b1
--- /dev/null
+++ b/test/fixtures/plugins/foo_plugin/app/javascript/controllers/bar_controller.js
@@ -0,0 +1,7 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="bar"
+export default class extends Controller {
+  connect() {
+  }
+}
diff --git a/test/fixtures/plugins/foo_plugin/app/javascript/locales/en.js b/test/fixtures/plugins/foo_plugin/app/javascript/locales/en.js
new file mode 100644
index 00000000000..a5b082869ee
--- /dev/null
+++ b/test/fixtures/plugins/foo_plugin/app/javascript/locales/en.js
@@ -0,0 +1 @@
+export const lang = {hello: 'hello'}
diff --git a/test/fixtures/plugins/foo_plugin/app/javascript/locales/ja.js b/test/fixtures/plugins/foo_plugin/app/javascript/locales/ja.js
new file mode 100644
index 00000000000..f55e2d990a5
--- /dev/null
+++ b/test/fixtures/plugins/foo_plugin/app/javascript/locales/ja.js
@@ -0,0 +1 @@
+export const lang = {hello: 'こんにちは'}
diff --git a/test/fixtures/plugins/foo_plugin/config/importmap.rb b/test/fixtures/plugins/foo_plugin/config/importmap.rb
new file mode 100644
index 00000000000..7c0f717b97a
--- /dev/null
+++ b/test/fixtures/plugins/foo_plugin/config/importmap.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+pin "foo"
+
+pin_all_from "app/javascript/locales", under: "locales"
+
+pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2c026ad1135..80e3a66c3ac 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -512,4 +512,10 @@ module Redmine
       end
     end
   end
+
+  module ImportmapTestHelper
+    def path_to_asset(asset)
+      resolver.resolve(asset)
+    end
+  end
 end
diff --git a/test/unit/lib/redmine/plugin_loader_test.rb b/test/unit/lib/redmine/plugin_loader_test.rb
index 095d0a76231..b0218c726d2 100644
--- a/test/unit/lib/redmine/plugin_loader_test.rb
+++ b/test/unit/lib/redmine/plugin_loader_test.rb
@@ -23,12 +23,41 @@ class Redmine::PluginLoaderTest < ActiveSupport::TestCase
   def setup
     clear_public
 
-    @klass = Redmine::PluginLoader
-    @klass.directory = Rails.root.join('test/fixtures/plugins')
-    @klass.load
+    Redmine::Plugin.clear
+    @original_loader = ::Redmine::Plugin.loader
+    loader = Redmine::PluginLoader.new directory: Rails.root.join('test/fixtures/plugins'), prefix: @original_loader.prefix
+    Redmine::Plugin.loader = loader
+
+    loader.public_directory = Rails.public_path.join(@original_loader.prefix)
+    loader.add_autoload_paths
+    loader.directories.each(&:run_initializer)
+
+    config = Rails.application.config.assets.deep_dup
+    Redmine::Plugin.all.each do |plugin|
+      paths = plugin.asset_paths
+      config.redmine_extension_paths << paths if paths.present?
+    end
+
+    assembly = Propshaft::Assembly.new(config)
+    assembly.extend Redmine::ImportmapTestHelper
+    assembly.load_path.clear_cache
+
+    loader.directories.each(&:draw_importmap)
+    json = Rails.application.importmap.to_json(resolver: assembly)
+    @importmap = JSON.parse(json)['imports']
+  end
+
+  test 'esmodule is loaded' do
+    assert_match %r{/assets/plugin_assets/foo_plugin/foo-\w{8}\.js}, @importmap['plugin_assets/foo_plugin/foo']
+    assert_match %r{/assets/plugin_assets/foo_plugin/locales/ja-\w{8}\.js}, @importmap['plugin_assets/foo_plugin/locales/ja']
+  end
+
+  test 'stimulus controller is loaded' do
+    assert_match %r{/assets/plugin_assets/foo_plugin/controllers/bar_controller-\w{8}\.js}, @importmap['controllers/bar_controller']
   end
 
   def teardown
+     Redmine::Plugin.loader = @original_loader
     clear_public
   end
 
-- 
2.47.3

