Project

General

Profile

Feature #39111 » 0001-Add-propshaft-to-enable-asset-pipeline.patch

Takashi Kato, 2023-09-24 17:44

View differences:

.gitignore
24 24
/log/mongrel_debug
25 25
/plugins/*
26 26
!/plugins/README
27
/public/assets/*
27 28
/public/dispatch.*
28 29
/public/plugin_assets/*
29 30
/public/themes/*
.hgignore
24 24
lib/redmine/scm/adapters/mercurial/redminehelper.pyo
25 25
log/*.log*
26 26
log/mongrel_debug
27
public/assets/*
27 28
public/dispatch.*
28 29
public/plugin_assets/*
29 30
tmp/*
Gemfile
21 21
gem 'net-pop', '~> 0.1.2'
22 22
gem 'net-smtp', '~> 0.3.3'
23 23
gem 'rexml', require: false if Gem.ruby_version >= Gem::Version.new('3.0')
24
gem 'propshaft', '~> 0.7.0'
24 25

  
25 26
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
26 27
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
app/helpers/application_helper.rb
1668 1668
    plugin = options.delete(:plugin)
1669 1669
    sources = sources.map do |source|
1670 1670
      if plugin
1671
        "/plugin_assets/#{plugin}/stylesheets/#{source}"
1671
        "plugin_assets/#{plugin}/#{source}"
1672 1672
      elsif current_theme && current_theme.stylesheets.include?(source)
1673 1673
        current_theme.stylesheet_path(source)
1674 1674
      else
......
1685 1685
  #
1686 1686
  def image_tag(source, options={})
1687 1687
    if plugin = options.delete(:plugin)
1688
      source = "/plugin_assets/#{plugin}/images/#{source}"
1688
      source = "plugin_assets/#{plugin}/#{source}"
1689 1689
    elsif current_theme && current_theme.images.include?(source)
1690 1690
      source = current_theme.image_path(source)
1691 1691
    end
......
1702 1702
    if plugin = options.delete(:plugin)
1703 1703
      sources = sources.map do |source|
1704 1704
        if plugin
1705
          "/plugin_assets/#{plugin}/javascripts/#{source}"
1705
          "plugin_assets/#{plugin}/#{source}"
1706 1706
        else
1707 1707
          source
1708 1708
        end
config/application.rb
85 85
    # for more options (same options as config.cache_store).
86 86
    config.redmine_search_cache_store = :memory_store
87 87

  
88
    # Paths for plugin and theme assets. Nothing is set here, as the actual
89
    # configuration is performed in the initializer.
90
    config.assets.redmine_extension_paths = []
91

  
88 92
    # Configure log level here so that additional environment file
89 93
    # can change it (environments/ENV.rb would take precedence over it)
90 94
    config.log_level = Rails.env.production? ? :info : :debug
config/environments/production.rb
93 93

  
94 94
  # No email in production log
95 95
  config.action_mailer.logger = nil
96

  
97
  config.assets.redmine_detect_update = true
96 98
end
config/initializers/10-patches.rb
162 162

  
163 163
Mime::SET << 'api'
164 164

  
165
# Adds asset_id parameters to assets like Rails 3 to invalidate caches in browser
166
module ActionView
167
  module Helpers
168
    module AssetUrlHelper
169
      @@cache_asset_timestamps = Rails.env.production?
170
      @@asset_timestamps_cache = {}
171
      @@asset_timestamps_cache_guard = Mutex.new
172

  
173
      def asset_path_with_asset_id(source, options = {})
174
        asset_id = rails_asset_id(source, options)
175
        unless asset_id.blank?
176
          source += "?#{asset_id}"
177
        end
178
        asset_path(source, options.merge(skip_pipeline: true))
179
      end
180
      alias :path_to_asset :asset_path_with_asset_id
181

  
182
      def rails_asset_id(source, options = {})
183
        if asset_id = ENV["RAILS_ASSET_ID"]
184
          asset_id
185
        else
186
          if @@cache_asset_timestamps && (asset_id = @@asset_timestamps_cache[source])
187
            asset_id
188
          else
189
            extname = compute_asset_extname(source, options)
190
            path = File.join(Rails.public_path, "#{source}#{extname}")
191
            exist = false
192
            if File.exist? path
193
              exist = true
194
            else
195
              path = File.join(Rails.public_path, public_compute_asset_path("#{source}#{extname}", options))
196
              if File.exist? path
197
                exist = true
198
              end
199
            end
200
            asset_id = exist ? File.mtime(path).to_i.to_s : ''
201

  
202
            if @@cache_asset_timestamps
203
              @@asset_timestamps_cache_guard.synchronize do
204
                @@asset_timestamps_cache[source] = asset_id
205
              end
206
            end
207

  
208
            asset_id
209
          end
210
        end
211
      end
212
    end
213
  end
214
end
215

  
216 165
# https://github.com/rack/rack/pull/1703
217 166
# TODO: remove this when Rack is updated to 3.0.0
218 167
require 'rack'
......
226 175
    end
227 176
  end
228 177
end
178

  
179
module Propshaft
180
  Assembly.prepend(Module.new do
181
    def initialize(config)
182
      super
183
      if Rails.application.config.assets.redmine_detect_update && (!manifest_path.exist? || manifest_outdated?)
184
        processor.process
185
      end
186
    end
187

  
188
    def manifest_outdated?
189
      !!load_path.asset_files.detect{|f| f.mtime > manifest_path.mtime}
190
    end
191

  
192
    def load_path
193
      @load_path ||= Redmine::AssetLoadPath.new(config)
194
    end
195
  end)
196

  
197
  Helper.prepend(Module.new do
198
    def compute_asset_path(path, options = {})
199
      super
200
    rescue MissingAssetError => e
201
      File.join Rails.application.assets.resolver.prefix, path
202
    end
203
  end)
204
end
config/initializers/30-redmine.rb
18 18
end
19 19

  
20 20
Redmine::PluginLoader.load
21
plugin_assets_reloader = Redmine::PluginLoader.create_assets_reloader
22

  
23
Rails.application.reloaders << plugin_assets_reloader
24
unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false
25
  plugin_assets_reloader.execute
26
end
27 21

  
28 22
Rails.application.config.to_prepare do
23
  default_paths = []
24
  default_paths << Rails.public_path.join('javascripts')
25
  default_paths << Rails.public_path.join('stylesheets')
26
  default_paths << Rails.public_path.join('images')
27
  Rails.application.config.assets.redmine_default_asset_path = Redmine::AssetPath.new(Rails.public_path, default_paths)
28

  
29 29
  Redmine::FieldFormat::RecordList.subclasses.each do |klass|
30 30
    klass.instance.reset_target_class
31 31
  end
32 32

  
33
  plugin_assets_reloader.execute_if_updated
33
  Redmine::Plugin.all.each do |plugin|
34
    paths = plugin.asset_paths
35
    Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
36
  end
37

  
38
  Redmine::Themes.themes.each do |theme|
39
    paths = theme.asset_paths
40
    Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
41
  end
34 42
end
lib/redmine/asset_path.rb
1
# frozen_string_literal: true
2
# Redmine - project management software
3
# Copyright (C) 2006-2023  Jean-Philippe Lang
4
#
5
# This program is free software; you can redistribute it and/or
6
# modify it under the terms of the GNU General Public License
7
# as published by the Free Software Foundation; either version 2
8
# of the License, or (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with this program; if not, write to the Free Software
17
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18

  
19
module Redmine
20
  class AssetPath
21

  
22
    attr_reader :paths, :prefix, :version
23

  
24
    def initialize(base_dir, paths, prefix=nil)
25
      @base_dir = base_dir
26
      @paths  = paths
27
      @prefix = prefix
28
      @transition = Transition.new(src: Set.new, dest: Set.new)
29
      @version = Rails.application.config.assets.version
30
    end
31

  
32
    def update(transition_map:, assets:)
33
      each_file do |file, intermediate_path, logical_path|
34
        @transition.add_src  intermediate_path, logical_path
35
        @transition.add_dest intermediate_path, logical_path
36

  
37
        asset = file.extname == '.css' ? Redmine::Asset.new(file,   logical_path: logical_path, version: version, transition_map: transition_map)
38
                                       : Propshaft::Asset.new(file, logical_path: logical_path, version: version)
39
        assets[asset.logical_path.to_s] ||= asset
40
      end
41
      @transition.update(transition_map)
42
      nil
43
    end
44

  
45
    def each_file
46
      paths.each do |path|
47
        without_dotfiles(all_files_from_tree(path)).each do |file|
48
          relative_path = file.relative_path_from(path).to_s
49
          logical_path  = prefix ? File.join(prefix, relative_path) : relative_path
50
          intermediate_path = Pathname.new("/#{prefix}").join(file.relative_path_from(@base_dir))
51
          yield file, intermediate_path, logical_path
52
        end
53
      end
54
    end
55

  
56
    private
57

  
58
    Transition = Struct.new(:src, :dest, keyword_init: true) do
59

  
60
      def add_src(file, logical_path)
61
        src.add  path_pair(file, logical_path) if file.extname == '.css'
62
      end
63

  
64
      def add_dest(file, logical_path)
65
        return if file.extname == '.js' || file.extname == '.map'
66

  
67
        # No parent-child directories are needed in dest.
68
        dirname = file.dirname
69
        if child = dest.find{|d| child_path? dirname, d[0]}
70
          dest.delete child
71
          dest.add path_pair(file, logical_path)
72
        elsif !dest.any?{|d| parent_path? dirname, d[0]}
73
          dest.add path_pair(file, logical_path)
74
        end
75
      end
76

  
77
      def path_pair(file, logical_path)
78
        [file.dirname, Pathname.new("/#{logical_path}").dirname]
79
      end
80

  
81
      def parent_path?(path, other)
82
        return nil if other == path
83

  
84
        path.ascend.any?{|v| v == other}
85
      end
86

  
87
      def child_path?(path, other)
88
        return nil if path == other
89

  
90
        other.ascend.any?{|v| v == path}
91
      end
92

  
93
      def update(transition_map)
94
        product = src.to_a.product(dest.to_a).select{|t| t[0] != t[1]}
95
        maps = product.map do |t|
96
          AssetPathMap.new(src: t[0][0], dest: t[1][0], logical_src: t[0][1], logical_dest: t[1][1])
97
        end
98

  
99
        maps.each do |m|
100
          if m.before != m.after
101
            transition_map[m.dirname] ||= {}
102
            transition_map[m.dirname][m.before] = m.after
103
          end
104
        end
105
      end
106
    end
107

  
108
    AssetPathMap = Struct.new(:src, :dest, :logical_src, :logical_dest, keyword_init: true) do
109

  
110
      def dirname
111
        key = logical_src.to_s.sub('/', '')
112
        key == '' ? '.' : key
113
      end
114

  
115
      def before
116
        dest.relative_path_from(src).to_s
117
      end
118

  
119
      def after
120
        logical_dest.relative_path_from(logical_src).to_s
121
      end
122
    end
123

  
124
    def without_dotfiles(files)
125
      files.reject { |file| file.basename.to_s.starts_with?(".") }
126
    end
127

  
128
    def all_files_from_tree(path)
129
      path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child }
130
    end
131
  end
132

  
133
  class AssetLoadPath < Propshaft::LoadPath
134

  
135
    attr_reader :extension_paths, :default_asset_path, :transition_map
136

  
137
    def initialize(config)
138
      @extension_paths    = config.redmine_extension_paths
139
      @default_asset_path = config.redmine_default_asset_path
140

  
141
      super(config.paths, version: config.version)
142
    end
143

  
144
    def asset_files
145
      Enumerator.new do |y|
146
        Rails.logger.info all_paths
147
        all_paths.each do |path|
148
          next unless path.exist?
149
          without_dotfiles(all_files_from_tree(path)).each do |file|
150
            y << file
151
          end
152
        end
153
      end
154
    end
155

  
156
    def assets_by_path
157
      merge_required = @cached_assets_by_path == nil
158

  
159
      super
160

  
161
      if merge_required
162
        @transition_map = {}
163
        default_asset_path.update(assets: @cached_assets_by_path, transition_map: @transition_map)
164

  
165
        extension_paths.each do |asset_path|
166
          # Support link from extension assets to assets in the application
167
          default_asset_path.each_file do |file, intermediate_path, logical_path|
168
            asset_path.instance_eval { @transition.add_dest intermediate_path, logical_path }
169
          end
170
          asset_path.update(assets: @cached_assets_by_path, transition_map: @transition_map)
171
        end
172
      end
173
      @cached_assets_by_path
174
    end
175

  
176
    def cache_sweeper
177
      @cache_sweeper ||= begin
178
        exts_to_watch  = Mime::EXTENSION_LOOKUP.map(&:first)
179
        files_to_watch = Array(all_paths).collect { |dir| [ dir.to_s, exts_to_watch ] }.to_h
180

  
181
        Rails.application.config.file_watcher.new([], files_to_watch) do
182
          clear_cache
183
        end
184
      end
185
    end
186

  
187
    def all_paths
188
      [paths, @default_paths, @extension_paths.map{|path| path.paths}].flatten.compact
189
    end
190

  
191
    def clear_cache
192
      @transition_map = nil
193
      super
194
    end
195
  end
196

  
197
  class Asset < Propshaft::Asset
198

  
199
    def initialize(file, logical_path:, version:, transition_map:)
200
      @transition_map = transition_map
201
      super(file, logical_path: logical_path, version: version)
202
    end
203

  
204
    def content
205
      if conversion = @transition_map[logical_path.dirname.to_s]
206
        convert_path super, conversion
207
      else
208
        super
209
      end
210
    end
211

  
212
    ASSET_URL_PATTERN = /(url\(\s*["']?([^"'\s)]+)\s*["']?\s*\))/
213
    def convert_path(input, conversion)
214
      input.gsub(ASSET_URL_PATTERN) do |matched|
215
        conversion.each do |key, val|
216
          matched.sub!(key, val)
217
        end
218
        matched
219
      end
220
    end
221
  end
222
end
lib/redmine/hook/view_listener.rb
34 34
      include ActionView::Helpers::TextHelper
35 35
      include Rails.application.routes.url_helpers
36 36
      include ApplicationHelper
37
      include Propshaft::Helper
37 38

  
38 39
      # Default to creating links using only the path.  Subclasses can
39 40
      # change this default as needed
lib/redmine/plugin.rb
186 186
      path.assets_dir
187 187
    end
188 188

  
189
    def asset_prefix
190
      File.join(self.class.public_directory.basename, id.to_s)
191
    end
192

  
193
    def asset_paths
194
      if path.has_assets_dir?
195
        base_dir = Pathname.new(path.assets_dir)
196
        paths = base_dir.children.filter_map{|child| child if child.directory? }
197
        Redmine::AssetPath.new(base_dir, paths, asset_prefix)
198
      end
199
    end
200

  
189 201
    def <=>(plugin)
190 202
      return nil unless plugin.is_a?(Plugin)
191 203

  
lib/redmine/themes.rb
91 91
      end
92 92

  
93 93
      def stylesheet_path(source)
94
        "/themes/#{dir}/stylesheets/#{source}"
94
        "#{asset_prefix}#{source}"
95 95
      end
96 96

  
97 97
      def image_path(source)
98
        "/themes/#{dir}/images/#{source}"
98
        "#{asset_prefix}#{source}"
99 99
      end
100 100

  
101 101
      def javascript_path(source)
102
        "/themes/#{dir}/javascripts/#{source}"
102
        "#{asset_prefix}#{source}"
103 103
      end
104 104

  
105 105
      def favicon_path
106
        "/themes/#{dir}/favicon/#{favicon}"
106
        "#{asset_prefix}#{favicon}"
107
      end
108

  
109
      def asset_prefix
110
        "themes/#{dir}/"
111
      end
112

  
113
      def asset_paths
114
        base_dir = Pathname.new(path)
115
        paths = base_dir.children.filter_map{|child| child if child.directory? &&
116
                                                              child.basename.to_s != "src" &&
117
                                                              !child.basename.to_s.start_with?('.') }
118
        Redmine::AssetPath.new(base_dir, paths, asset_prefix)
107 119
      end
108 120

  
109 121
      private
test/fixtures/asset_path/foo/images/baz/baz.svg
1
<svg height="100" width="100">
2
  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
3
</svg>
test/fixtures/asset_path/foo/images/foo.svg
1
<svg height="100" width="100">
2
  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
3
</svg>
test/fixtures/asset_path/foo/stylesheets/bar/bar.css
1
.foo {
2
  background-image: url("../../images/baz/baz.svg");
3
}
test/fixtures/asset_path/foo/stylesheets/foo.css
1
.foo {
2
  background-image: url("../images/foo.svg");
3
}
test/integration/lib/redmine/hook_test.rb
102 102
    assert_response :success
103 103
    assert_select 'p', :text => 'ContentForInsideHook content'
104 104
    assert_select 'head' do
105
      assert_select 'script[src="/plugin_assets/test_plugin/javascripts/test_plugin.js"]'
106
      assert_select 'link[href="/plugin_assets/test_plugin/stylesheets/test_plugin.css"]'
105
      assert_select 'script[src="/assets/plugin_assets/test_plugin/test_plugin.js"]'
106
      assert_select 'link[href="/assets/plugin_assets/test_plugin/test_plugin.css"]'
107 107
    end
108 108
  end
109 109

  
test/unit/lib/redmine/asset_path_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2023  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../test_helper'
21

  
22
class Redmine::AssetPathTest < ActiveSupport::TestCase
23
  def setup
24
    assets_dir = Rails.root.join('test/fixtures/asset_path/foo')
25
    @asset_path = Redmine::AssetPath.new(assets_dir, assets_dir.children.filter_map{|child| child if child.directory? }, 'plugin_assets/foo/')
26
    @assets = {}
27
    @transition_map = {}
28
    @asset_path.update(transition_map: @transition_map, assets: @assets)
29
  end
30

  
31
  test "asset path size" do
32
    assert_equal 2, @asset_path.paths.size
33
  end
34

  
35
  test "@transition_map does not contain directories with parent-child relationships" do
36
    assert_equal '.', @transition_map['plugin_assets/foo']['../images']
37
    assert_nil   @transition_map['plugin_assets/foo/bar']['../../images/baz']
38
    assert_equal '..', @transition_map['plugin_assets/foo/bar']['../../images']
39
  end
40

  
41
  test "update assets" do
42
    assert_not_nil @assets['plugin_assets/foo/foo.css']
43
    assert_not_nil @assets['plugin_assets/foo/foo.svg']
44
  end
45
end
(1-1/10)