diff options
author | Marius Balteanu <marius.balteanu@zitec.com> | 2024-01-25 05:38:33 +0000 |
---|---|---|
committer | Marius Balteanu <marius.balteanu@zitec.com> | 2024-01-25 05:38:33 +0000 |
commit | c99bb27e27e3c32ca5aa174de238fe3adf089310 (patch) | |
tree | 08c94446e85ecba3bec0167ce4571260fb9652a3 | |
parent | f803778cde85c6d58f1e5d8740d3e46852f39a0e (diff) | |
download | redmine-c99bb27e27e3c32ca5aa174de238fe3adf089310.tar.gz redmine-c99bb27e27e3c32ca5aa174de238fe3adf089310.zip |
Add Propshaft library to enable the asset pipeline without modifying existing assets (#39111).
Patch by Takashi Kato (@tohosaku).
git-svn-id: https://svn.redmine.org/redmine/trunk@22626 e93f8b46-1217-0410-a6f0-8f06a7374b81
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .hgignore | 1 | ||||
-rw-r--r-- | Gemfile | 1 | ||||
-rw-r--r-- | app/helpers/application_helper.rb | 6 | ||||
-rw-r--r-- | config/application.rb | 4 | ||||
-rw-r--r-- | config/environments/production.rb | 2 | ||||
-rw-r--r-- | config/initializers/10-patches.rb | 70 | ||||
-rw-r--r-- | config/initializers/30-redmine.rb | 22 | ||||
-rw-r--r-- | lib/redmine/asset_path.rb | 213 | ||||
-rw-r--r-- | lib/redmine/hook/view_listener.rb | 1 | ||||
-rw-r--r-- | lib/redmine/plugin.rb | 12 | ||||
-rw-r--r-- | lib/redmine/themes.rb | 20 | ||||
-rw-r--r-- | test/fixtures/asset_path/foo/images/baz/baz.svg | 3 | ||||
-rw-r--r-- | test/fixtures/asset_path/foo/images/foo.svg | 3 | ||||
-rw-r--r-- | test/fixtures/asset_path/foo/stylesheets/bar/bar.css | 3 | ||||
-rw-r--r-- | test/fixtures/asset_path/foo/stylesheets/foo.css | 3 | ||||
-rw-r--r-- | test/integration/lib/redmine/hook_test.rb | 4 | ||||
-rw-r--r-- | test/unit/lib/redmine/asset_path_test.rb | 44 |
18 files changed, 350 insertions, 63 deletions
diff --git a/.gitignore b/.gitignore index 5f6413192..f25b049a5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ /log/mongrel_debug /plugins/* !/plugins/README +/public/assets/* /public/dispatch.* /public/plugin_assets/* /public/themes/* @@ -24,6 +24,7 @@ lib/redmine/scm/adapters/mercurial/redminehelper.pyc lib/redmine/scm/adapters/mercurial/redminehelper.pyo log/*.log* log/mongrel_debug +public/assets/* public/dispatch.* public/plugin_assets/* tmp/* @@ -14,6 +14,7 @@ gem 'i18n', '~> 1.14.1' gem 'rbpdf', '~> 1.21.3' gem 'addressable' gem 'rubyzip', '~> 2.3.0' +gem 'propshaft', '~> 0.8.0' # Ruby Standard Gems gem 'csv', '~> 3.2.8' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4d87f6075..43fbe47a6 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1668,7 +1668,7 @@ module ApplicationHelper plugin = options.delete(:plugin) sources = sources.map do |source| if plugin - "/plugin_assets/#{plugin}/stylesheets/#{source}" + "plugin_assets/#{plugin}/#{source}" elsif current_theme && current_theme.stylesheets.include?(source) current_theme.stylesheet_path(source) else @@ -1685,7 +1685,7 @@ module ApplicationHelper # def image_tag(source, options={}) if plugin = options.delete(:plugin) - source = "/plugin_assets/#{plugin}/images/#{source}" + source = "plugin_assets/#{plugin}/#{source}" elsif current_theme && current_theme.images.include?(source) source = current_theme.image_path(source) end @@ -1702,7 +1702,7 @@ module ApplicationHelper if plugin = options.delete(:plugin) sources = sources.map do |source| if plugin - "/plugin_assets/#{plugin}/javascripts/#{source}" + "plugin_assets/#{plugin}/#{source}" else source end diff --git a/config/application.rb b/config/application.rb index f3b6e82fd..069796185 100644 --- a/config/application.rb +++ b/config/application.rb @@ -88,6 +88,10 @@ module RedmineApp # Sets default plugin directory config.redmine_plugins_directory = 'plugins' + # Paths for plugin and theme assets. Nothing is set here, as the actual + # configuration is performed in the initializer. + config.assets.redmine_extension_paths = [] + # Configure log level here so that additional environment file # can change it (environments/ENV.rb would take precedence over it) config.log_level = Rails.env.production? ? :info : :debug diff --git a/config/environments/production.rb b/config/environments/production.rb index edbffba9e..acba108ca 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -93,4 +93,6 @@ Rails.application.configure do # No email in production log config.action_mailer.logger = nil + + config.assets.redmine_detect_update = true end diff --git a/config/initializers/10-patches.rb b/config/initializers/10-patches.rb index 933da218e..1d932eb1f 100644 --- a/config/initializers/10-patches.rb +++ b/config/initializers/10-patches.rb @@ -147,53 +147,29 @@ end Mime::SET << 'api' -# Adds asset_id parameters to assets like Rails 3 to invalidate caches in browser -module ActionView - module Helpers - module AssetUrlHelper - @@cache_asset_timestamps = Rails.env.production? - @@asset_timestamps_cache = {} - @@asset_timestamps_cache_guard = Mutex.new - - def asset_path_with_asset_id(source, options = {}) - asset_id = rails_asset_id(source, options) - unless asset_id.blank? - source += "?#{asset_id}" - end - asset_path(source, options.merge(skip_pipeline: true)) - end - alias :path_to_asset :asset_path_with_asset_id - - def rails_asset_id(source, options = {}) - if asset_id = ENV["RAILS_ASSET_ID"] - asset_id - else - if @@cache_asset_timestamps && (asset_id = @@asset_timestamps_cache[source]) - asset_id - else - extname = compute_asset_extname(source, options) - path = File.join(Rails.public_path, "#{source}#{extname}") - exist = false - if File.exist? path - exist = true - else - path = File.join(Rails.public_path, public_compute_asset_path("#{source}#{extname}", options)) - if File.exist? path - exist = true - end - end - asset_id = exist ? File.mtime(path).to_i.to_s : '' - - if @@cache_asset_timestamps - @@asset_timestamps_cache_guard.synchronize do - @@asset_timestamps_cache[source] = asset_id - end - end - - asset_id - end - end +module Propshaft + Assembly.prepend(Module.new do + def initialize(config) + super + if Rails.application.config.assets.redmine_detect_update && (!manifest_path.exist? || manifest_outdated?) + processor.process end end - end + + def manifest_outdated? + !!load_path.asset_files.detect{|f| f.mtime > manifest_path.mtime} + end + + def load_path + @load_path ||= Redmine::AssetLoadPath.new(config) + end + end) + + Helper.prepend(Module.new do + def compute_asset_path(path, options = {}) + super + rescue MissingAssetError => e + File.join Rails.application.assets.resolver.prefix, path + end + end) end diff --git a/config/initializers/30-redmine.rb b/config/initializers/30-redmine.rb index 62969e4a6..f19373b65 100644 --- a/config/initializers/30-redmine.rb +++ b/config/initializers/30-redmine.rb @@ -21,17 +21,25 @@ if secret.present? end Redmine::PluginLoader.load -plugin_assets_reloader = Redmine::PluginLoader.create_assets_reloader - -Rails.application.reloaders << plugin_assets_reloader -unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false - plugin_assets_reloader.execute -end Rails.application.config.to_prepare do + default_paths = [] + default_paths << Rails.public_path.join('javascripts') + default_paths << Rails.public_path.join('stylesheets') + default_paths << Rails.public_path.join('images') + Rails.application.config.assets.redmine_default_asset_path = Redmine::AssetPath.new(Rails.public_path, default_paths) + Redmine::FieldFormat::RecordList.subclasses.each do |klass| klass.instance.reset_target_class end - plugin_assets_reloader.execute_if_updated + Redmine::Plugin.all.each do |plugin| + paths = plugin.asset_paths + Rails.application.config.assets.redmine_extension_paths << paths if paths.present? + end + + Redmine::Themes.themes.each do |theme| + paths = theme.asset_paths + Rails.application.config.assets.redmine_extension_paths << paths if paths.present? + end end diff --git a/lib/redmine/asset_path.rb b/lib/redmine/asset_path.rb new file mode 100644 index 000000000..834674395 --- /dev/null +++ b/lib/redmine/asset_path.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2023 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 + class AssetPath + + attr_reader :paths, :prefix, :version + + def initialize(base_dir, paths, prefix=nil) + @base_dir = base_dir + @paths = paths + @prefix = prefix + @transition = Transition.new(src: Set.new, dest: Set.new) + @version = Rails.application.config.assets.version + end + + def update(transition_map:, assets:) + each_file do |file, intermediate_path, logical_path| + @transition.add_src intermediate_path, logical_path + @transition.add_dest intermediate_path, logical_path + asset = file.extname == '.css' ? Redmine::Asset.new(file, logical_path: logical_path, version: version, transition_map: transition_map) + : Propshaft::Asset.new(file, logical_path: logical_path, version: version) + assets[asset.logical_path.to_s] ||= asset + end + @transition.update(transition_map) + nil + end + + def each_file + paths.each do |path| + without_dotfiles(all_files_from_tree(path)).each do |file| + relative_path = file.relative_path_from(path).to_s + logical_path = prefix ? File.join(prefix, relative_path) : relative_path + intermediate_path = Pathname.new("/#{prefix}").join(file.relative_path_from(@base_dir)) + yield file, intermediate_path, logical_path + end + end + end + + private + + Transition = Struct.new(:src, :dest, keyword_init: true) do + + def add_src(file, logical_path) + src.add path_pair(file, logical_path) if file.extname == '.css' + end + + def add_dest(file, logical_path) + return if file.extname == '.js' || file.extname == '.map' + # No parent-child directories are needed in dest. + dirname = file.dirname + if child = dest.find{|d| child_path? dirname, d[0]} + dest.delete child + dest.add path_pair(file, logical_path) + elsif !dest.any?{|d| parent_path? dirname, d[0]} + dest.add path_pair(file, logical_path) + end + end + + def path_pair(file, logical_path) + [file.dirname, Pathname.new("/#{logical_path}").dirname] + end + + def parent_path?(path, other) + return nil if other == path + path.ascend.any?{|v| v == other} + end + + def child_path?(path, other) + return nil if path == other + other.ascend.any?{|v| v == path} + end + + def update(transition_map) + product = src.to_a.product(dest.to_a).select{|t| t[0] != t[1]} + maps = product.map do |t| + AssetPathMap.new(src: t[0][0], dest: t[1][0], logical_src: t[0][1], logical_dest: t[1][1]) + end + maps.each do |m| + if m.before != m.after + transition_map[m.dirname] ||= {} + transition_map[m.dirname][m.before] = m.after + end + end + end + end + + AssetPathMap = Struct.new(:src, :dest, :logical_src, :logical_dest, keyword_init: true) do + + def dirname + key = logical_src.to_s.sub('/', '') + key == '' ? '.' : key + end + + def before + dest.relative_path_from(src).to_s + end + + def after + logical_dest.relative_path_from(logical_src).to_s + end + end + + def without_dotfiles(files) + files.reject { |file| file.basename.to_s.starts_with?(".") } + end + + def all_files_from_tree(path) + path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child } + end + end + + class AssetLoadPath < Propshaft::LoadPath + + attr_reader :extension_paths, :default_asset_path, :transition_map + + def initialize(config) + @extension_paths = config.redmine_extension_paths + @default_asset_path = config.redmine_default_asset_path + super(config.paths, version: config.version) + end + + def asset_files + Enumerator.new do |y| + Rails.logger.info all_paths + all_paths.each do |path| + next unless path.exist? + without_dotfiles(all_files_from_tree(path)).each do |file| + y << file + end + end + end + end + + def assets_by_path + merge_required = @cached_assets_by_path == nil + super + if merge_required + @transition_map = {} + default_asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map) + extension_paths.each do |asset_path| + # Support link from extension assets to assets in the application + default_asset_path.each_file do |file, intermediate_path, logical_path| + asset_path.instance_eval { @transition.add_dest intermediate_path, logical_path } + end + asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map) + end + end + @cached_assets_by_path + end + + def cache_sweeper + @cache_sweeper ||= begin + exts_to_watch = Mime::EXTENSION_LOOKUP.map(&:first) + files_to_watch = Array(all_paths).collect { |dir| [ dir.to_s, exts_to_watch ] }.to_h + Rails.application.config.file_watcher.new([], files_to_watch) do + clear_cache + end + end + end + + def all_paths + [paths, default_asset_path.paths, extension_paths.map{|path| path.paths}].flatten.compact + end + + def clear_cache + @transition_map = nil + super + end + end + + class Asset < Propshaft::Asset + def initialize(file, logical_path:, version:, transition_map:) + @transition_map = transition_map + super(file, logical_path: logical_path, version: version) + end + + def content + if conversion = @transition_map[logical_path.dirname.to_s] + convert_path super, conversion + else + super + end + end + + ASSET_URL_PATTERN = /(url\(\s*["']?([^"'\s)]+)\s*["']?\s*\))/ + + def convert_path(input, conversion) + input.gsub(ASSET_URL_PATTERN) do |matched| + conversion.each do |key, val| + matched.sub!(key, val) + end + matched + end + end + end +end diff --git a/lib/redmine/hook/view_listener.rb b/lib/redmine/hook/view_listener.rb index 2a9efd34a..225112903 100644 --- a/lib/redmine/hook/view_listener.rb +++ b/lib/redmine/hook/view_listener.rb @@ -34,6 +34,7 @@ module Redmine include ActionView::Helpers::TextHelper include Rails.application.routes.url_helpers include ApplicationHelper + include Propshaft::Helper # Default to creating links using only the path. Subclasses can # change this default as needed diff --git a/lib/redmine/plugin.rb b/lib/redmine/plugin.rb index 8107a5a39..c9b2c07ba 100644 --- a/lib/redmine/plugin.rb +++ b/lib/redmine/plugin.rb @@ -186,6 +186,18 @@ module Redmine path.assets_dir end + def asset_prefix + File.join(self.class.public_directory.basename, id.to_s) + end + + def asset_paths + if path.has_assets_dir? + base_dir = Pathname.new(path.assets_dir) + paths = base_dir.children.filter_map{|child| child if child.directory? } + Redmine::AssetPath.new(base_dir, paths, asset_prefix) + end + end + def <=>(plugin) return nil unless plugin.is_a?(Plugin) diff --git a/lib/redmine/themes.rb b/lib/redmine/themes.rb index 608a13a12..6126fde5e 100644 --- a/lib/redmine/themes.rb +++ b/lib/redmine/themes.rb @@ -91,19 +91,31 @@ module Redmine end def stylesheet_path(source) - "/themes/#{dir}/stylesheets/#{source}" + "#{asset_prefix}#{source}" end def image_path(source) - "/themes/#{dir}/images/#{source}" + "#{asset_prefix}#{source}" end def javascript_path(source) - "/themes/#{dir}/javascripts/#{source}" + "#{asset_prefix}#{source}" end def favicon_path - "/themes/#{dir}/favicon/#{favicon}" + "#{asset_prefix}#{favicon}" + end + + def asset_prefix + "themes/#{dir}/" + end + + def asset_paths + base_dir = Pathname.new(path) + paths = base_dir.children.filter_map{|child| child if child.directory? && + child.basename.to_s != "src" && + !child.basename.to_s.start_with?('.') } + Redmine::AssetPath.new(base_dir, paths, asset_prefix) end private diff --git a/test/fixtures/asset_path/foo/images/baz/baz.svg b/test/fixtures/asset_path/foo/images/baz/baz.svg new file mode 100644 index 000000000..5fe0a4a07 --- /dev/null +++ b/test/fixtures/asset_path/foo/images/baz/baz.svg @@ -0,0 +1,3 @@ +<svg height="100" width="100"> + <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> +</svg> diff --git a/test/fixtures/asset_path/foo/images/foo.svg b/test/fixtures/asset_path/foo/images/foo.svg new file mode 100644 index 000000000..5fe0a4a07 --- /dev/null +++ b/test/fixtures/asset_path/foo/images/foo.svg @@ -0,0 +1,3 @@ +<svg height="100" width="100"> + <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> +</svg> diff --git a/test/fixtures/asset_path/foo/stylesheets/bar/bar.css b/test/fixtures/asset_path/foo/stylesheets/bar/bar.css new file mode 100644 index 000000000..49611eb81 --- /dev/null +++ b/test/fixtures/asset_path/foo/stylesheets/bar/bar.css @@ -0,0 +1,3 @@ +.foo { + background-image: url("../../images/baz/baz.svg"); +} diff --git a/test/fixtures/asset_path/foo/stylesheets/foo.css b/test/fixtures/asset_path/foo/stylesheets/foo.css new file mode 100644 index 000000000..d66f1433d --- /dev/null +++ b/test/fixtures/asset_path/foo/stylesheets/foo.css @@ -0,0 +1,3 @@ +.foo { + background-image: url("../images/foo.svg"); +} diff --git a/test/integration/lib/redmine/hook_test.rb b/test/integration/lib/redmine/hook_test.rb index 9eacded52..e7f2fa35a 100644 --- a/test/integration/lib/redmine/hook_test.rb +++ b/test/integration/lib/redmine/hook_test.rb @@ -102,8 +102,8 @@ class HookTest < Redmine::IntegrationTest assert_response :success assert_select 'p', :text => 'ContentForInsideHook content' assert_select 'head' do - assert_select 'script[src="/plugin_assets/test_plugin/javascripts/test_plugin.js"]' - assert_select 'link[href="/plugin_assets/test_plugin/stylesheets/test_plugin.css"]' + assert_select 'script[src="/assets/plugin_assets/test_plugin/test_plugin.js"]' + assert_select 'link[href="/assets/plugin_assets/test_plugin/test_plugin.css"]' end end diff --git a/test/unit/lib/redmine/asset_path_test.rb b/test/unit/lib/redmine/asset_path_test.rb new file mode 100644 index 000000000..e820f33fd --- /dev/null +++ b/test/unit/lib/redmine/asset_path_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# Redmine - project management software +# Copyright (C) 2006-2023 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. + +require_relative '../../../test_helper' + +class Redmine::AssetPathTest < ActiveSupport::TestCase + def setup + assets_dir = Rails.root.join('test/fixtures/asset_path/foo') + @asset_path = Redmine::AssetPath.new(assets_dir, assets_dir.children.filter_map{|child| child if child.directory? }, 'plugin_assets/foo/') + @assets = {} + @transition_map = {} + @asset_path.update(transition_map: @transition_map, assets: @assets) + end + + test "asset path size" do + assert_equal 2, @asset_path.paths.size + end + + test "@transition_map does not contain directories with parent-child relationships" do + assert_equal '.', @transition_map['plugin_assets/foo']['../images'] + assert_nil @transition_map['plugin_assets/foo/bar']['../../images/baz'] + assert_equal '..', @transition_map['plugin_assets/foo/bar']['../../images'] + end + + test "update assets" do + assert_not_nil @assets['plugin_assets/foo/foo.css'] + assert_not_nil @assets['plugin_assets/foo/foo.svg'] + end +end |