diff --git a/Gemfile b/Gemfile index 49d96fb1..2bb6fb48 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem "coderay", "~> 0.9.7" gem "i18n", "~> 0.4.2" gem "rubytree", "~> 0.5.2", :require => 'tree' gem "rdoc", ">= 2.4.2" +gem "liquid", "~> 2.3.0" # Needed only on RUBY_VERSION = 1.8, ruby 1.9+ compatible interpreters should bring their csv gem "fastercsv", "~> 1.5.0", :platforms => [:ruby_18, :jruby, :mingw_18] diff --git a/app/drops/base_drop.rb b/app/drops/base_drop.rb new file mode 100644 index 00000000..18355cc1 --- /dev/null +++ b/app/drops/base_drop.rb @@ -0,0 +1,23 @@ +class BaseDrop < Liquid::Drop + def initialize(object) + @object = object unless object.respond_to?(:visible?) && !object.visible? + end + + # Defines a Liquid method on the drop that is allowed to call the + # Ruby method directly. Best used for attributes. + # + # Based on Module#liquid_methods + def self.allowed_methods(*allowed_methods) + class_eval do + allowed_methods.each do |sym| + define_method sym do + if @object.respond_to?(:public_send) + @object.public_send(sym) rescue nil + else + @object.send(sym) rescue nil + end + end + end + end + end +end diff --git a/app/drops/issue_drop.rb b/app/drops/issue_drop.rb new file mode 100644 index 00000000..40d3f572 --- /dev/null +++ b/app/drops/issue_drop.rb @@ -0,0 +1,65 @@ +class IssueDrop < BaseDrop + allowed_methods :id + allowed_methods :subject + allowed_methods :description + allowed_methods :project + allowed_methods :tracker + allowed_methods :status + allowed_methods :due_date + allowed_methods :category + allowed_methods :assigned_to + allowed_methods :priority + allowed_methods :fixed_version + allowed_methods :author + allowed_methods :created_on + allowed_methods :updated_on + allowed_methods :start_date + allowed_methods :done_ratio + allowed_methods :estimated_hours + allowed_methods :parent + + def custom_field(name) + return '' unless name.present? + custom_field = IssueCustomField.find_by_name(name.strip) + return '' unless custom_field.present? + custom_value = @object.custom_value_for(custom_field) + if custom_value.present? + return custom_value.value + else + return '' + end + end + + # TODO: both required, method_missing for Ruby and before_method for Liquid + + # Allows accessing custom fields by their name: + # + # - issue.the_name_of_player => CustomField(:name => "The name Of Player") + # + def before_method(method_sym) + if custom_field_with_matching_name = has_custom_field_with_matching_name?(method_sym) + custom_field(custom_field_with_matching_name.name) + else + super + end + end + + # Allows accessing custom fields by their name: + # + # - issue.the_name_of_player => CustomField(:name => "The name Of Player") + # + def method_missing(method_sym, *arguments, &block) + if custom_field_with_matching_name = has_custom_field_with_matching_name?(method_sym) + custom_field(custom_field_with_matching_name.name) + else + super + end + end + +private + def has_custom_field_with_matching_name?(method_sym) + custom_field_with_matching_name = @object.available_custom_fields.detect {|custom_field| + custom_field.name.downcase.underscore.gsub(' ','_') == method_sym.to_s + } + end +end diff --git a/app/drops/issue_status_drop.rb b/app/drops/issue_status_drop.rb new file mode 100644 index 00000000..88d1a9dc --- /dev/null +++ b/app/drops/issue_status_drop.rb @@ -0,0 +1,3 @@ +class IssueStatusDrop < BaseDrop + allowed_methods :name +end diff --git a/app/drops/principal_drop.rb b/app/drops/principal_drop.rb new file mode 100644 index 00000000..24a94ea9 --- /dev/null +++ b/app/drops/principal_drop.rb @@ -0,0 +1,3 @@ +class PrincipalDrop < BaseDrop + allowed_methods :name +end diff --git a/app/drops/project_drop.rb b/app/drops/project_drop.rb new file mode 100644 index 00000000..f14a7799 --- /dev/null +++ b/app/drops/project_drop.rb @@ -0,0 +1,3 @@ +class ProjectDrop < BaseDrop + allowed_methods :name, :identifier +end diff --git a/app/drops/tracker_drop.rb b/app/drops/tracker_drop.rb new file mode 100644 index 00000000..3bcf4a0e --- /dev/null +++ b/app/drops/tracker_drop.rb @@ -0,0 +1,3 @@ +class TrackerDrop < BaseDrop + allowed_methods :name +end diff --git a/app/drops/wiki_page_drop.rb b/app/drops/wiki_page_drop.rb new file mode 100644 index 00000000..27c9be4f --- /dev/null +++ b/app/drops/wiki_page_drop.rb @@ -0,0 +1,3 @@ +class WikiPageDrop < BaseDrop + allowed_methods :title +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5e24410f..f621138c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -16,7 +16,6 @@ require 'forwardable' require 'cgi' module ApplicationHelper - include Redmine::WikiFormatting::Macros::Definitions include Redmine::I18n include GravatarHelper::PublicMethods @@ -461,7 +460,29 @@ module ApplicationHelper project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil) only_path = options.delete(:only_path) == false ? false : true - text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr) { |macro, args| exec_macro(macro, obj, args) } + begin + ChiliProject::Liquid::Legacy.run_macros(text) + liquid_template = ChiliProject::Liquid::Template.parse(text) + liquid_variables = get_view_instance_variables_for_liquid + liquid_variables.merge!({'current_user' => User.current}) + liquid_variables.merge!({'toc' => '{{toc}}'}) # Pass toc through to replace later + liquid_variables.merge!(ChiliProject::Liquid::Variables.macro_backwards_compatibility) + + # Pass :view in a register so this view (with helpers) can be used inside of a tag + text = liquid_template.render(liquid_variables, :registers => {:view => self, :object => obj, :attribute => attr}) + + # Add Liquid errors to the log + if Rails.logger && Rails.logger.debug? + msg = "" + liquid_template.errors.each do |exception| + msg << "[Liquid Error] #{exception.message}\n:\n#{exception.backtrace.join("\n")}" + msg << "\n\n" + end + Rails.logger.debug msg + end + rescue Liquid::SyntaxError + # Skip Liquid if there is a syntax error + end @parsed_headings = [] text = parse_non_pre_blocks(text) do |text| @@ -940,7 +961,7 @@ module ApplicationHelper # Expands the current menu item using JavaScript based on the params def expand_current_menu current_menu_class = - case + case when params[:controller] == "timelog" "reports" when params[:controller] == 'projects' && params[:action] == 'changelog' @@ -959,7 +980,7 @@ module ApplicationHelper params[:controller] end - + javascript_tag("jQuery.menu_expand({ menuItem: '.#{current_menu_class}' });") end @@ -1006,4 +1027,23 @@ module ApplicationHelper def link_to_content_update(text, url_params = {}, html_options = {}) link_to(text, url_params, html_options) end + + def get_view_instance_variables_for_liquid + self.instance_variables.reject do |ivar| + ivar.match(/@_/) || # Rails "internal" variables: @_foo + ivar.match(/@template/) || + ivar == '@output_buffer' || + ivar == '@cookies' || + ivar == '@helpers' || + ivar == '@real_format' || + ivar == '@assigns_added' || + ivar == '@assigns' || + ivar == '@view_paths' || + ivar == '@controller' + end.inject({}) do |acc,ivar| + acc[ivar.sub('@','')] = instance_variable_get(ivar) + acc + end + end + end diff --git a/app/models/issue.rb b/app/models/issue.rb index cc754014..233e2bb1 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -103,6 +103,10 @@ class Issue < ActiveRecord::Base (usr || User.current).allowed_to?(:view_issues, self.project) end + def to_liquid + IssueDrop.new(self) + end + def after_initialize if new_record? # set default values for new records only diff --git a/app/models/issue_status.rb b/app/models/issue_status.rb index 7078237d..637aaafb 100644 --- a/app/models/issue_status.rb +++ b/app/models/issue_status.rb @@ -28,6 +28,10 @@ class IssueStatus < ActiveRecord::Base IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default? end + def to_liquid + IssueStatusDrop.new(self) + end + # Returns the default status for new issues def self.default find(:first, :conditions =>["is_default=?", true]) diff --git a/app/models/principal.rb b/app/models/principal.rb index 00207f5e..3c390bb2 100644 --- a/app/models/principal.rb +++ b/app/models/principal.rb @@ -31,6 +31,10 @@ class Principal < ActiveRecord::Base before_create :set_default_empty_values + def to_liquid + PrincipalDrop.new(self) + end + def name(formatter = nil) to_s end diff --git a/app/models/project.rb b/app/models/project.rb index 36c2606d..7ea21188 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -83,6 +83,10 @@ class Project < ActiveRecord::Base named_scope :all_public, { :conditions => { :is_public => true } } named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } } + def to_liquid + ProjectDrop.new(self) + end + def initialize(attributes = nil) super @@ -131,6 +135,11 @@ class Project < ActiveRecord::Base end end + # Is the project visible to the current user + def visible? + User.current.allowed_to?(:view_project, self) + end + def self.allowed_to_condition(user, permission, options={}) base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}" if perm = Redmine::AccessControl.permission(permission) diff --git a/app/models/tracker.rb b/app/models/tracker.rb index 353645f7..94b75845 100644 --- a/app/models/tracker.rb +++ b/app/models/tracker.rb @@ -35,6 +35,10 @@ class Tracker < ActiveRecord::Base name <=> tracker.name end + def to_liquid + TrackerDrop.new(self) + end + def self.all find(:all, :order => 'position') end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 16566f05..d2208dfa 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -53,6 +53,10 @@ class WikiPage < ActiveRecord::Base end end + def to_liquid + WikiPageDrop.new(self) + end + def visible?(user=User.current) !user.nil? && user.allowed_to?(:view_wiki_pages, project) end diff --git a/config/environment.rb b/config/environment.rb index 3c174193..0ae61ffe 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -49,6 +49,9 @@ Rails::Initializer.run do |config| # (by default production uses :info, the others :debug) # config.log_level = :debug + # Liquid drops + config.autoload_paths += %W( #{RAILS_ROOT}/app/drops ) + # Enable page/fragment caching by setting a file-based store # (remember to create the caching directory and make it readable to the application) # config.action_controller.cache_store = :file_store, "#{RAILS_ROOT}/tmp/cache" diff --git a/lib/chili_project/liquid.rb b/lib/chili_project/liquid.rb new file mode 100644 index 00000000..f2783d6a --- /dev/null +++ b/lib/chili_project/liquid.rb @@ -0,0 +1,9 @@ +require 'chili_project/liquid/liquid_ext' +require 'chili_project/liquid/filters' +require 'chili_project/liquid/tags' + +module ChiliProject + module Liquid + Liquid::Template.file_system = FileSystem.new + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/file_system.rb b/lib/chili_project/liquid/file_system.rb new file mode 100644 index 00000000..d6fe60f9 --- /dev/null +++ b/lib/chili_project/liquid/file_system.rb @@ -0,0 +1,18 @@ +module ChiliProject + module Liquid + class FileSystem + def read_template_file(template_name, context) + raise ::Liquid::FileSystemError.new("Page not found") if template_name.blank? + project = Project.find(context['project'].identifier) if context['project'].present? + + cross_project_page = template_name.include?(':') + page = Wiki.find_page(template_name.to_s.strip, :project => (cross_project_page ? nil : project)) + if page.nil? || !page.visible? + raise ::Liquid::FileSystemError.new("No such page '#{template_name}'") + end + + page.content + end + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/filters.rb b/lib/chili_project/liquid/filters.rb new file mode 100644 index 00000000..baa2ed5d --- /dev/null +++ b/lib/chili_project/liquid/filters.rb @@ -0,0 +1,15 @@ +module ChiliProject + module Liquid + module Filters + def default(input, default) + input.to_s.strip.present? ? input : default + end + + def strip(input) + input.to_s.strip + end + end + + Template.register_filter(Filters) + end +end diff --git a/lib/chili_project/liquid/legacy.rb b/lib/chili_project/liquid/legacy.rb new file mode 100644 index 00000000..c2a23768 --- /dev/null +++ b/lib/chili_project/liquid/legacy.rb @@ -0,0 +1,57 @@ +module ChiliProject + module Liquid + # Legacy is used to support older Redmine style macros by converting + # them to Liquid objects (tags, filters) on the fly by doing basic + # string substitution. This is done before the Liquid processing + # so the converted macros work like normal + # + module Legacy + # Holds the list of legacy macros + # + # @param [Regexp] :match The regex to match on the legacy macro + # @param [String] :replace The string to replace with. E.g. "%" converts + # "{{ }}" to "{% %}" + # @param [String] :new_name The new name of the Liquid object + def self.macros + @macros ||= {} + end + + # "Runs" legacy macros by doing a gsub of their values to the new Liquid ones + # + # @param [String] content The pre-Liquid content + def self.run_macros(content) + macros.each do |macro_name, macro| + next unless macro[:match].present? && macro[:replace].present? + content.gsub!(macro[:match]) do |match| + # Use block form so $1 and $2 are set properly + "{#{macro[:replace]} #{macro[:new_name]} '#{$2}' #{macro[:replace]}}" + end + end + end + + # Add support for a legacy macro syntax that was converted to liquid + # + # @param [String] name The legacy macro name + # @param [Symbol] liquid_type The type of Liquid object to use. Supported: :tag + # @param [optional, String] new_name The new name of the liquid object, used + # to rename a macro + def self.add(name, liquid_type, new_name=nil) + new_name = name unless new_name.present? + case liquid_type + when :tag + + macros[name.to_s] = { + # Example values the regex matches + # {{name}} + # {{ name }} + # {{ name 'arg' }} + # {{ name('arg') }} + :match => Regexp.new(/\{\{(#{name})(?:\(([^\}]*)\))?\}\}/), + :replace => "%", + :new_name => new_name + } + end + end + end + end +end diff --git a/lib/chili_project/liquid/liquid_ext.rb b/lib/chili_project/liquid/liquid_ext.rb new file mode 100644 index 00000000..d001afe4 --- /dev/null +++ b/lib/chili_project/liquid/liquid_ext.rb @@ -0,0 +1,8 @@ +module ChiliProject + module Liquid + module LiquidExt + ::Liquid::Block.send(:include, Block) + ::Liquid::Context.send(:include, Context) + end + end +end diff --git a/lib/chili_project/liquid/liquid_ext/block.rb b/lib/chili_project/liquid/liquid_ext/block.rb new file mode 100644 index 00000000..f37dffc4 --- /dev/null +++ b/lib/chili_project/liquid/liquid_ext/block.rb @@ -0,0 +1,22 @@ +module ChiliProject + module Liquid + module LiquidExt + module Block + def self.included(base) + base.send(:include, InstanceMethods) + base.class_eval do + alias_method_chain :render_all, :cleaned_whitespace + end + end + + module InstanceMethods + def render_all_with_cleaned_whitespace(list, context) + # Remove the leading newline in a block's content + list[0].sub!(/\A\r?\n/, "") if list[0].is_a?(String) + render_all_without_cleaned_whitespace(list, context) + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/liquid_ext/context.rb b/lib/chili_project/liquid/liquid_ext/context.rb new file mode 100644 index 00000000..1dc38739 --- /dev/null +++ b/lib/chili_project/liquid/liquid_ext/context.rb @@ -0,0 +1,40 @@ +module ChiliProject + module Liquid + module LiquidExt + module Context + def self.included(base) + base.send(:include, InstanceMethods) + base.class_eval do + alias_method_chain :handle_error, :formatting + end + end + + module InstanceMethods + def handle_error_with_formatting(e) + error = handle_error_without_formatting(e) + escaped_error = registers[:view].send(:h, error) rescue CGI::escapeHTML(error) + + html = '
' + escaped_error + '
' + html_result(html) + end + + def html_result(html) + key = nil + while key.nil? || html_results.has_key?(key) + random = ActiveSupport::SecureRandom.hex(10) + # This string must be passed untouched through Liquid and textile + # It mustn't be changed in any way by any rendering stage. + key = "!!html_results.#{random}!!" + end + html_results[key] = html + key + end + + def html_results + registers[:html_results] ||= {} + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/tags.rb b/lib/chili_project/liquid/tags.rb new file mode 100644 index 00000000..41a28f7b --- /dev/null +++ b/lib/chili_project/liquid/tags.rb @@ -0,0 +1,38 @@ +module ChiliProject::Liquid + module Tags + class TagError < StandardError; end + + def self.register_tag(name, klass, options={}) + if options[:html] + html_class = Class.new do + def render(context) + result = @tag.render(context) + context.html_result(result) + end + + def method_missing(*args, &block) + @tag.send(*args, &block) + end + end + html_class.send :define_method, :initialize do |*args| + @tag = klass.new(*args) + end + ::Liquid::Template.register_tag(name, html_class) + else + ::Liquid::Template.register_tag(name, klass) + end + end + + register_tag('child_pages', ChildPages, :html => true) + register_tag('hello_world', HelloWorld) + register_tag('include', Include, :html => true) + register_tag('tag_list', TagList, :html => true) + register_tag('variable_list', VariableList, :html => true) + end +end + +# FIXME: remove the deprecated syntax for 4.0, provide a way to migrate +# existing pages to the new syntax. +ChiliProject::Liquid::Legacy.add('child_pages', :tag) +ChiliProject::Liquid::Legacy.add('hello_world', :tag) +ChiliProject::Liquid::Legacy.add('include', :tag) diff --git a/lib/chili_project/liquid/tags/child_pages.rb b/lib/chili_project/liquid/tags/child_pages.rb new file mode 100644 index 00000000..ca373a3e --- /dev/null +++ b/lib/chili_project/liquid/tags/child_pages.rb @@ -0,0 +1,60 @@ +module ChiliProject::Liquid::Tags + class ChildPages < Tag + def initialize(tag_name, markup, tokens) + markup = markup.strip.gsub(/["']/, '') + if markup.present? + tag_args = markup.split(',') + @args, @options = extract_macro_options(tag_args, :parent) + else + @args = [] + @options = {} + end + super + end + + def render(context) + # inside of a project + @project = Project.find(context['project'].identifier) if context['project'].present? + + if @args.present? + page_name = @args.first.to_s + cross_project_page = page_name.include?(':') + + page = Wiki.find_page(page_name, :project => (cross_project_page ? nil : @project)) + # FIXME: :object and :attribute should be variables, not registers + elsif context.registers[:object].is_a?(WikiContent) + page = context.registers[:object].page + page_name = page.title + elsif @project + return render_all_pages(context) + else + raise TagError.new('With no argument, this tag can be called from projects only.') + end + + raise TagError.new("No such page '#{page_name}'") if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project) + pages = ([page] + page.descendants).group_by(&:parent_id) + context.registers[:view].render_page_hierarchy(pages, @options[:parent] ? page.parent_id : page.id) + end + + private + def render_all_pages(context) + return '' unless @project.wiki.present? && @project.wiki.pages.present? + raise TagError.new('Page not found') if !User.current.allowed_to?(:view_wiki_pages, @project) + + context.registers[:view].render_page_hierarchy(@project.wiki.pages.group_by(&:parent_id)) + end + + # @param args [Array, String] An array of strings in "key=value" format + # @param keys [Hash, Symbol] List of keyword args to extract + def extract_macro_options(args, *keys) + options = {} + args.each do |arg| + if arg.to_s.gsub(/["']/,'').strip =~ %r{^(.+)\=(.+)$} && keys.include?($1.downcase.to_sym) + options[$1.downcase.to_sym] = $2 + args.pop + end + end + return [args, options] + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/tags/hello_world.rb b/lib/chili_project/liquid/tags/hello_world.rb new file mode 100644 index 00000000..7eff7654 --- /dev/null +++ b/lib/chili_project/liquid/tags/hello_world.rb @@ -0,0 +1,11 @@ +module ChiliProject::Liquid::Tags + class HelloWorld < Tag + def initialize(tag_name, markup, tokens) + super + end + + def render(context) + "Hello world!" + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/tags/include.rb b/lib/chili_project/liquid/tags/include.rb new file mode 100644 index 00000000..6de3df92 --- /dev/null +++ b/lib/chili_project/liquid/tags/include.rb @@ -0,0 +1,82 @@ +#-- copyright +# ChiliProject is a project management system. +# +# Copyright (C) 2010-2011 the ChiliProject Team +# +# 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. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module ChiliProject::Liquid::Tags + class Include < ::Liquid::Include + + # This method follows the basic flow of the default include in liquid + # We just add some additional flexibility. This method can be removed once + # https://github.com/Shopify/liquid/pull/78 got accepted + def render(context) + context.stack do + template = _read_template_from_file_system(context) + partial = Liquid::Template.parse _template_source(template) + variable = context[@variable_name || @template_name[1..-2]] + + @attributes.each do |key, value| + context[key] = context[value] + end + + if variable.is_a?(Array) + variable.collect do |variable| + context[@template_name[1..-2]] = variable + _render_partial(partial, template, context) + end + else + context[@template_name[1..-2]] = variable + _render_partial(partial, template, context) + end + end + end + + private + def break_circle(context) + context.registers[:included_pages] ||= [] + + project = context['project'].identifier if context['project'].present? + template_name = context[@template_name] + cross_project_page = template_name.include?(':') + page_title = cross_project_page ? template_name : "#{project}:#{template_name}" + + raise ::Liquid::FileSystemError.new("Circular inclusion detected") if context.registers[:included_pages].include?(page_title) + context.registers[:included_pages] << page_title + + yield + ensure + context.registers[:included_pages].pop + end + + def _template_source(wiki_content) + wiki_content.text + end + + def _render_partial(partial, template, context) + break_circle(context) do + textile = partial.render(context) + + # Call textilizable on the view so all of the helpers are loaded + # based on the view and not this tag + context.registers[:view].textilizable(textile, :attachments => template.page.attachments, :headings => false, :object => template) + end + end + + def _read_template_from_file_system(context) + wiki_content = super + + # Set the new project to that additional includes use the correct + # base project + context['project'] = wiki_content.page.wiki.project + wiki_content + end + end +end diff --git a/lib/chili_project/liquid/tags/tag.rb b/lib/chili_project/liquid/tags/tag.rb new file mode 100644 index 00000000..4b7783dd --- /dev/null +++ b/lib/chili_project/liquid/tags/tag.rb @@ -0,0 +1,4 @@ +module ChiliProject::Liquid::Tags + class Tag < ::Liquid::Tag + end +end diff --git a/lib/chili_project/liquid/tags/tag_list.rb b/lib/chili_project/liquid/tags/tag_list.rb new file mode 100644 index 00000000..e409e8ca --- /dev/null +++ b/lib/chili_project/liquid/tags/tag_list.rb @@ -0,0 +1,14 @@ +module ChiliProject::Liquid::Tags + class TagList < Tag + include ActionView::Helpers::TagHelper + + def render(context) + content_tag('p', "Tags:") + + content_tag('ul', + ::Liquid::Template.tags.keys.sort.collect {|tag_name| + content_tag('li', content_tag('code', h(tag_name))) + }.join('') + ) + end + end +end \ No newline at end of file diff --git a/lib/chili_project/liquid/tags/variable_list.rb b/lib/chili_project/liquid/tags/variable_list.rb new file mode 100644 index 00000000..51b85a48 --- /dev/null +++ b/lib/chili_project/liquid/tags/variable_list.rb @@ -0,0 +1,15 @@ +module ChiliProject::Liquid::Tags + class VariableList < Tag + include ActionView::Helpers::TagHelper + + def render(context) + out = '' + context.environments.first.keys.sort.each do |liquid_variable| + next if liquid_variable == 'text' # internal variable + out << content_tag('li', content_tag('code', h(liquid_variable))) + end if context.environments.present? + + content_tag('p', "Variables:") + content_tag('ul', out) + end + end +end diff --git a/lib/chili_project/liquid/template.rb b/lib/chili_project/liquid/template.rb new file mode 100644 index 00000000..955cbf32 --- /dev/null +++ b/lib/chili_project/liquid/template.rb @@ -0,0 +1,96 @@ +module ChiliProject + module Liquid + class Template < ::Liquid::Template + # creates a new Template object from liquid source code + def self.parse(source) + template = self.new + template.parse(source) + template + end + + + def context_from_render_options(*args) + # This method is pulled out straight from the original + # Liquid::Template#render + context = case args.first + when ::Liquid::Context + args.shift + when Hash + ::Liquid::Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors) + when nil + ::Liquid::Context.new(assigns, instance_assigns, registers, @rethrow_errors) + else + raise ArgumentError, "Expect Hash or Liquid::Context as parameter" + end + + case args.last + when Hash + options = args.pop + + if options[:registers].is_a?(Hash) + self.registers.merge!(options[:registers]) + end + + if options[:filters] + context.add_filters(options[:filters]) + end + + when Module + context.add_filters(args.pop) + when Array + context.add_filters(args.pop) + end + context + end + + # Render takes a hash with local variables. + # + # if you use the same filters over and over again consider registering them globally + # with Template.register_filter + # + # Following options can be passed: + # + # * filters : array with local filters + # * registers : hash with register variables. Those can be accessed from + # filters and tags and might be useful to integrate liquid more with its host application + # + def render(*args) + return '' if @root.nil? + + context = context_from_render_options(*args) + context.registers[:html_results] ||= {} + + # ENTER THE RENDERING STAGE + + # 1. Render the input as Liquid + # Some tags might register final HTML output in this stage. + begin + # for performance reasons we get a array back here. join will make a string out of it + result = @root.render(context) + result.respond_to?(:join) ? result.join : result + ensure + @errors = context.errors + end + + # 2. Perform the Wiki markup transformation (e.g. Textile) + obj = context.registers[:object] + attr = context.registers[:attribute] + result = Redmine::WikiFormatting.to_html(Setting.text_formatting, result, :object => obj, :attribute => attr) + + # 3. Now finally, replace the captured raw HTML bits in the final content + length = nil + # replace HTML results until we can find no additional variables + while length != context.registers[:html_results].length do + length = context.registers[:html_results].length + context.registers[:html_results].delete_if do |key, value| + # We use the block variant to avoid the evaluation of escaped + # characters in +value+ during substitution. + result.sub!(key) { |match| value } + end + end + + result + end + end + end +end diff --git a/lib/chili_project/liquid/variables.rb b/lib/chili_project/liquid/variables.rb new file mode 100644 index 00000000..ad23da38 --- /dev/null +++ b/lib/chili_project/liquid/variables.rb @@ -0,0 +1,14 @@ +module ChiliProject + module Liquid + module Variables + # Liquid "variables" that are used for backwards compatability with macros + # + # Variables are used in liquid like {{var}} + def self.macro_backwards_compatibility + { + 'macro_list' => "Use the '{% variable_list %}' tag to see all Liquid variables and '{% tag_list %}' to see all of the Liquid tags." + } + end + end + end +end diff --git a/lib/redmine/wiki_formatting.rb b/lib/redmine/wiki_formatting.rb index cf5f4516..ac9ae734 100644 --- a/lib/redmine/wiki_formatting.rb +++ b/lib/redmine/wiki_formatting.rb @@ -41,7 +41,7 @@ module Redmine end def to_html(format, text, options = {}, &block) - text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, options[:object], options[:attribute]) + if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, options[:object], options[:attribute]) # Text retrieved from the cache store may be frozen # We need to dup it so we can do in-place substitutions with gsub! cache_store.fetch cache_key do @@ -50,10 +50,6 @@ module Redmine else formatter_for(format).new(text).to_html end - if block_given? - execute_macros(text, block) - end - text end # Returns a cache key for the given text +format+, +object+ and +attribute+ or nil if no caching should be done @@ -67,33 +63,6 @@ module Redmine def cache_store ActionController::Base.cache_store end - - MACROS_RE = / - (!)? # escaping - ( - \{\{ # opening tag - ([\w]+) # macro name - (\(([^\}]*)\))? # optional arguments - \}\} # closing tag - ) - /x unless const_defined?(:MACROS_RE) - - # Macros substitution - def execute_macros(text, macros_runner) - text.gsub!(MACROS_RE) do - esc, all, macro = $1, $2, $3.downcase - args = ($5 || '').split(',').each(&:strip) - if esc.nil? - begin - macros_runner.call(macro, args) - rescue => e - "
Error executing the #{macro} macro (#{e})
" - end || all - else - all - end - end - end end end end diff --git a/lib/redmine/wiki_formatting/macros.rb b/lib/redmine/wiki_formatting/macros.rb index 8c01eac0..28ea57d7 100644 --- a/lib/redmine/wiki_formatting/macros.rb +++ b/lib/redmine/wiki_formatting/macros.rb @@ -12,106 +12,55 @@ # See doc/COPYRIGHT.rdoc for more details. #++ +# DECREACATED SINCE 3.0 - TO BE REMOVED IN 4.0 +# The whole macro concept is deprecated. It is to be completely replaced by +# Liquid tags and variables. + +require 'dispatcher' + module Redmine module WikiFormatting module Macros - module Definitions - def exec_macro(name, obj, args) - method_name = "macro_#{name}" - send(method_name, obj, args) if respond_to?(method_name) - end - - def extract_macro_options(args, *keys) - options = {} - while args.last.to_s.strip =~ %r{^(.+)\=(.+)$} && keys.include?($1.downcase.to_sym) - options[$1.downcase.to_sym] = $2 - args.pop - end - return [args, options] - end - end - - @@available_macros = {} + @available_macros = {} class << self - # Called with a block to define additional macros. - # Macro blocks accept 2 arguments: - # * obj: the object that is rendered - # * args: macro arguments - # - # Plugins can use this method to define new macros: - # - # Redmine::WikiFormatting::Macros.register do - # desc "This is my macro" - # macro :my_macro do |obj, args| - # "My macro output" - # end - # end def register(&block) + ActiveSupport::Deprecation.warn("Macros are deprecated. Use Liquid filters and tags instead", caller.drop(3)) class_eval(&block) if block_given? end private + # Sets description for the next macro to be defined + def desc(txt) + @desc = txt + end + # Defines a new macro with the given name and block. def macro(name, &block) name = name.to_sym if name.is_a?(String) - @@available_macros[name] = @@desc || '' - @@desc = nil + @available_macros[name] = @desc || '' + @desc = nil raise "Can not create a macro without a block!" unless block_given? - Definitions.send :define_method, "macro_#{name}".downcase, &block + + tag = Class.new(::Liquid::Tag) do + def initialize(tag_name, markup, tokens) + if markup =~ self.class::Syntax + @args = $1[1..-2].split(',').collect(&:strip) + else + raise ::Liquid::SyntaxError.new("Syntax error in tag '#{name}'") + end + end + end + tag.send :define_method, :render do |context| + context.registers[:view].instance_exec context.registers[:object], @args, &block + end + tag.const_set 'Syntax', /(#{::Liquid::QuotedFragment})/ + + Dispatcher.to_prepare do + ChiliProject::Liquid::Tags.register_tag(name, tag, :html => true) + ChiliProject::Liquid::Legacy.add(name, :tag) + end end - - # Sets description for the next macro to be defined - def desc(txt) - @@desc = txt - end - end - - # Builtin macros - desc "Sample macro." - macro :hello_world do |obj, args| - "Hello world! Object: #{obj.class.name}, " + (args.empty? ? "Called with no argument." : "Arguments: #{args.join(', ')}") - end - - desc "Displays a list of all available macros, including description if available." - macro :macro_list do - out = '' - @@available_macros.keys.collect(&:to_s).sort.each do |macro| - out << content_tag('dt', content_tag('code', macro)) - out << content_tag('dd', textilizable(@@available_macros[macro.to_sym])) - end - content_tag('dl', out) - end - - desc "Displays a list of child pages. With no argument, it displays the child pages of the current wiki page. Examples:\n\n" + - " !{{child_pages}} -- can be used from a wiki page only\n" + - " !{{child_pages(Foo)}} -- lists all children of page Foo\n" + - " !{{child_pages(Foo, parent=1)}} -- same as above with a link to page Foo" - macro :child_pages do |obj, args| - args, options = extract_macro_options(args, :parent) - page = nil - if args.size > 0 - page = Wiki.find_page(args.first.to_s, :project => @project) - elsif obj.is_a?(WikiContent) - page = obj.page - else - raise 'With no argument, this macro can be called from wiki pages only.' - end - raise 'Page not found' if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project) - pages = ([page] + page.descendants).group_by(&:parent_id) - render_page_hierarchy(pages, options[:parent] ? page.parent_id : page.id) - end - - desc "Include a wiki page. Example:\n\n !{{include(Foo)}}\n\nor to include a page of a specific project wiki:\n\n !{{include(projectname:Foo)}}" - macro :include do |obj, args| - page = Wiki.find_page(args.first.to_s, :project => @project) - raise 'Page not found' if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project) - @included_wiki_pages ||= [] - raise 'Circular inclusion detected' if @included_wiki_pages.include?(page.title) - @included_wiki_pages << page.title - out = textilizable(page.content, :text, :attachments => page.attachments, :headings => false) - @included_wiki_pages.pop - out end end end diff --git a/test/fixtures/wiki_contents.yml b/test/fixtures/wiki_contents.yml index bf2797b8..060ec167 100644 --- a/test/fixtures/wiki_contents.yml +++ b/test/fixtures/wiki_contents.yml @@ -3,7 +3,7 @@ wiki_contents_001: text: |- h1. CookBook documentation - {{child_pages}} + {% child_pages %} Some updated [[documentation]] here with gzipped history updated_on: 2007-03-07 00:10:51 +01:00 @@ -17,7 +17,7 @@ wiki_contents_002: This is a link to a ticket: #2 And this is an included page: - {{include(Page with an inline image)}} + {% include 'Page with an inline image' %} updated_on: 2007-03-08 00:18:07 +01:00 page_id: 2 id: 2 diff --git a/test/functional/wiki_controller_test.rb b/test/functional/wiki_controller_test.rb index d8b442df..b583b728 100644 --- a/test/functional/wiki_controller_test.rb +++ b/test/functional/wiki_controller_test.rb @@ -18,8 +18,8 @@ require 'wiki_controller' class WikiController; def rescue_action(e) raise e end; end class WikiControllerTest < ActionController::TestCase - fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules, :wikis, :wiki_pages, :wiki_contents, :journals, :attachments - + fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules, :wikis, :wiki_pages, :wiki_contents, :journals, :attachments, :enumerations + def setup @controller = WikiController.new @request = ActionController::TestRequest.new diff --git a/test/unit/helpers/application_helper_test.rb b/test/unit/helpers/application_helper_test.rb index d1a6e255..9f85b101 100644 --- a/test/unit/helpers/application_helper_test.rb +++ b/test/unit/helpers/application_helper_test.rb @@ -593,7 +593,7 @@ RAW h1. Included -{{include(Child_1)}} +{% include 'Child_1' %} RAW expected = '