mirror of
https://github.com/redmine/redmine.git
synced 2025-11-07 13:55:52 +01:00
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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/*
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
213
lib/redmine/asset_path.rb
Normal file
213
lib/redmine/asset_path.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
3
test/fixtures/asset_path/foo/images/baz/baz.svg
vendored
Normal file
3
test/fixtures/asset_path/foo/images/baz/baz.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg height="100" width="100">
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 117 B |
3
test/fixtures/asset_path/foo/images/foo.svg
vendored
Normal file
3
test/fixtures/asset_path/foo/images/foo.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg height="100" width="100">
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 117 B |
3
test/fixtures/asset_path/foo/stylesheets/bar/bar.css
vendored
Normal file
3
test/fixtures/asset_path/foo/stylesheets/bar/bar.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.foo {
|
||||
background-image: url("../../images/baz/baz.svg");
|
||||
}
|
||||
3
test/fixtures/asset_path/foo/stylesheets/foo.css
vendored
Normal file
3
test/fixtures/asset_path/foo/stylesheets/foo.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.foo {
|
||||
background-image: url("../images/foo.svg");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
44
test/unit/lib/redmine/asset_path_test.rb
Normal file
44
test/unit/lib/redmine/asset_path_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user