project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
only_path = options.delete(:only_path) == false ? false : true
+ text = text.dup
+ macros = catch_macros(text)
text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
@parsed_headings = []
@current_section = 0 if options[:edit_section_links]
parse_sections(text, project, obj, attr, only_path, options)
- text = parse_non_pre_blocks(text) do |text|
- [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_macros].each do |method_name|
+ text = parse_non_pre_blocks(text, obj, macros) do |text|
+ [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
send method_name, text, project, obj, attr, only_path, options
end
end
text.html_safe
end
- def parse_non_pre_blocks(text)
+ def parse_non_pre_blocks(text, obj, macros)
s = StringScanner.new(text)
tags = []
parsed = ''
text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
if tags.empty?
yield text
+ inject_macros(text, obj, macros) if macros.any?
+ else
+ inject_macros(text, obj, macros, false) if macros.any?
end
parsed << text
if tag
end
end
- MACROS_RE = /
+ MACROS_RE = /(
(!)? # escaping
(
\{\{ # opening tag
(\((.*?)\))? # optional arguments
\}\} # closing tag
)
- /x unless const_defined?(:MACROS_RE)
+ )/x unless const_defined?(:MACROS_RE)
+
+ MACRO_SUB_RE = /(
+ \{\{
+ macro\((\d+)\)
+ \}\}
+ )/x unless const_defined?(:MACROS_SUB_RE)
- # Macros substitution
- def parse_macros(text, project, obj, attr, only_path, options)
+ # Extracts macros from text
+ def catch_macros(text)
+ macros = {}
text.gsub!(MACROS_RE) do
- esc, all, macro, args = $1, $2, $3.downcase, $5.to_s
- if esc.nil?
- begin
- exec_macro(macro, obj, args)
- rescue => e
- "<div class=\"flash error\">Error executing the <strong>#{macro}</strong> macro (#{e})</div>"
- end || all
+ all, macro = $1, $4.downcase
+ if macro_exists?(macro) || all =~ MACRO_SUB_RE
+ index = macros.size
+ macros[index] = all
+ "{{macro(#{index})}}"
else
all
end
end
+ macros
+ end
+
+ # Executes and replaces macros in text
+ def inject_macros(text, obj, macros, execute=true)
+ text.gsub!(MACRO_SUB_RE) do
+ all, index = $1, $2.to_i
+ orig = macros.delete(index)
+ if execute && orig && orig =~ MACROS_RE
+ esc, all, macro, args = $2, $3, $4.downcase, $6.to_s
+ if esc.nil?
+ h(exec_macro(macro, obj, args) || all)
+ else
+ h(all)
+ end
+ elsif orig
+ h(orig)
+ else
+ h(all)
+ end
+ end
end
TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+require 'digest/md5'
+
module Redmine
module WikiFormatting
class StaleSectionError < Exception; end
end
def to_html(format, text, options = {})
- text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, options[:object], options[:attribute])
+ text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, 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
(formatter.instance_methods & ['update_section', :update_section]).any?
end
- # Returns a cache key for the given text +format+, +object+ and +attribute+ or nil if no caching should be done
- def cache_key_for(format, object, attribute)
- if object && attribute && !object.new_record? && object.respond_to?(:updated_on) && !format.blank?
- "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{object.updated_on.to_s(:number)}"
+ # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
+ def cache_key_for(format, text, object, attribute)
+ if object && attribute && !object.new_record? && format.present?
+ "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
end
end
module WikiFormatting
module Macros
module Definitions
+ # Returns true if +name+ is the name of an existing macro
+ def macro_exists?(name)
+ Redmine::WikiFormatting::Macros.available_macros.key?(name.to_sym)
+ end
+
def exec_macro(name, obj, args)
macro_options = Redmine::WikiFormatting::Macros.available_macros[name.to_sym]
return unless macro_options
unless macro_options[:parse_args] == false
args = args.split(',').map(&:strip)
end
- send(method_name, obj, args) if respond_to?(method_name)
+
+ begin
+ send(method_name, obj, args) if respond_to?(method_name)
+ rescue => e
+ "<div class=\"flash error\">Error executing the <strong>#{h name}</strong> macro (#{h e.to_s})</div>".html_safe
+ end
end
def extract_macro_options(args, *keys)
# 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(', ')}")
+ h("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."
assert_equal '<p>no args args: c,d</p>', textilizable("{{foo}} {{foo(c,d)}}")
end
- def test_macro_hello_world
+ def test_macro_should_receive_the_object_as_argument_when_with_object_and_attribute
+ issue = Issue.find(1)
+ issue.description = "{{hello_world}}"
+ assert_equal '<p>Hello world! Object: Issue, Called with no argument.</p>', textilizable(issue, :description)
+ end
+
+ def test_macro_should_receive_the_object_as_argument_when_called_with_object_option
text = "{{hello_world}}"
- assert textilizable(text).match(/Hello world!/)
- # escaping
+ assert_equal '<p>Hello world! Object: Issue, Called with no argument.</p>', textilizable(text, :object => Issue.find(1))
+ end
+
+ def test_macro_exception_should_be_displayed
+ Redmine::WikiFormatting::Macros.macro :exception do |obj, args|
+ raise "My message"
+ end
+
+ text = "{{exception}}"
+ assert_include '<div class="flash error">Error executing the <strong>exception</strong> macro (My message)</div>', textilizable(text)
+ end
+
+ def test_macro_arguments_should_not_be_parsed_by_formatters
+ text = '{{hello_world(http://www.redmine.org, #1)}}'
+ assert_include 'Arguments: http://www.redmine.org, #1', textilizable(text)
+ end
+
+ def test_exclamation_mark_should_not_run_macros
text = "!{{hello_world}}"
assert_equal '<p>{{hello_world}}</p>', textilizable(text)
end
+ def test_exclamation_mark_should_escape_macros
+ text = "!{{hello_world(<tag>)}}"
+ assert_equal '<p>{{hello_world(<tag>)}}</p>', textilizable(text)
+ end
+
+ def test_unknown_macros_should_not_be_replaced
+ text = "{{unknown}}"
+ assert_equal '<p>{{unknown}}</p>', textilizable(text)
+ end
+
+ def test_unknown_macros_should_parsed_as_text
+ text = "{{unknown(*test*)}}"
+ assert_equal '<p>{{unknown(<strong>test</strong>)}}</p>', textilizable(text)
+ end
+
+ def test_unknown_macros_should_be_escaped
+ text = "{{unknown(<tag>)}}"
+ assert_equal '<p>{{unknown(<tag>)}}</p>', textilizable(text)
+ end
+
+ def test_html_safe_macro_output_should_not_be_escaped
+ Redmine::WikiFormatting::Macros.macro :safe_macro do |obj, args|
+ "<tag>".html_safe
+ end
+ assert_equal '<p><tag></p>', textilizable("{{safe_macro}}")
+ end
+
+ def test_macro_hello_world
+ text = "{{hello_world}}"
+ assert textilizable(text).match(/Hello world!/)
+ end
+
+ def test_macro_hello_world_should_escape_arguments
+ text = "{{hello_world(<tag>)}}"
+ assert_include 'Arguments: <tag>', textilizable(text)
+ end
+
def test_macro_macro_list
text = "{{macro_list}}"
assert_match %r{<code>hello_world</code>}, textilizable(text)
@project = Project.find(1)
# include a page of the current project wiki
text = "{{include(Another page)}}"
- assert textilizable(text).match(/This is a link to a ticket/)
+ assert_include 'This is a link to a ticket', textilizable(text)
@project = nil
# include a page of a specific project wiki
text = "{{include(ecookbook:Another page)}}"
- assert textilizable(text).match(/This is a link to a ticket/)
+ assert_include 'This is a link to a ticket', textilizable(text)
text = "{{include(ecookbook:)}}"
- assert textilizable(text).match(/CookBook documentation/)
+ assert_include 'CookBook documentation', textilizable(text)
text = "{{include(unknowidentifier:somepage)}}"
- assert textilizable(text).match(/Page not found/)
+ assert_include 'Page not found', textilizable(text)
end
def test_macro_child_pages
assert_include 'test.png not found',
textilizable("{{thumbnail(test.png)}}", :object => Issue.find(14))
end
+
+ def test_macros_should_not_be_executed_in_pre_tags
+ text = <<-RAW
+{{hello_world(foo)}}
+
+<pre>
+{{hello_world(pre)}}
+!{{hello_world(pre)}}
+</pre>
+
+{{hello_world(bar)}}
+RAW
+
+ expected = <<-EXPECTED
+<p>Hello world! Object: NilClass, Arguments: foo</p>
+
+<pre>
+{{hello_world(pre)}}
+!{{hello_world(pre)}}
+</pre>
+
+<p>Hello world! Object: NilClass, Arguments: bar</p>
+EXPECTED
+
+ assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(text).gsub(%r{[\r\n\t]}, '')
+ end
+
+ def test_macros_should_be_escaped_in_pre_tags
+ text = "<pre>{{hello_world(<tag>)}}</pre>"
+ assert_equal "<pre>{{hello_world(<tag>)}}</pre>", textilizable(text)
+ end
+
+ def test_macros_should_not_mangle_next_macros_outputs
+ text = '{{macro(2)}} !{{macro(2)}} {{hello_world(foo)}}'
+ assert_equal '<p>{{macro(2)}} {{macro(2)}} Hello world! Object: NilClass, Arguments: foo</p>', textilizable(text)
+ end
end
require File.expand_path('../../../../test_helper', __FILE__)
class Redmine::WikiFormattingTest < ActiveSupport::TestCase
+ fixtures :issues
def test_textile_formatter
assert_equal Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting.formatter_for('textile')
assert_equal false, Redmine::WikiFormatting.supports_section_edit?
end
end
+
+ def test_cache_key_for_saved_object_should_no_be_nil
+ assert_not_nil Redmine::WikiFormatting.cache_key_for('textile', 'Text', Issue.find(1), :description)
+ end
end