module Erector # A Widget is the center of the Erector universe. # # To create a widget, extend Erector::Widget and implement # the +content+ method. Inside this method you may call any of the tag methods like +span+ or +p+ to emit HTML/XML # tags. # # You can also define a widget on the fly by passing a block to +new+. This block will get executed when the widget's # +content+ method is called. # # To render a widget from the outside, instantiate it and call its +to_s+ method. # # A widget's +new+ method optionally accepts an options hash. Entries in this hash are converted to instance # variables, and +attr_reader+ accessors are defined for each. # # TODO: You can add runtime input checking via the +needs+ macro. If any of the variables named via # +needs+ are absent, an exception is thrown. Optional variables are specified with +wants+. If a variable appears # in the options hash that is in neither the +needs+ nor +wants+ lists, then that too provokes an exception. # This mechanism is meant to ameliorate development-time confusion about exactly what parameters are supported # by a given widget, avoiding confusing runtime NilClass errors. # # To call one widget from another, inside the parent widget's +content+ method, instantiate the child widget and call # the +widget+ method. This assures that the same output stream # is used, which gives better performance than using +capture+ or +to_s+. It also preserves the indentation and # helpers of the enclosing class. # # In this documentation we've tried to keep the distinction clear between methods that *emit* text and those that # *return* text. "Emit" means that it writes to the output stream; "return" means that it returns a string # like a normal method and leaves it up to the caller to emit that string if it wants. class Widget class << self def all_tags Erector::Widget.full_tags + Erector::Widget.empty_tags end # tags which are always self-closing def empty_tags ['area', 'base', 'br', 'col', 'frame', 'hr', 'img', 'input', 'link', 'meta'] end # tags which can contain other stuff def full_tags [ 'a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'body', 'button', 'caption', 'center', 'cite', 'code', 'colgroup', 'dd', 'del', 'dfn', 'div', 'dl', 'dt', 'em', 'fieldset', 'form', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'html', 'i', 'iframe', 'ins', 'kbd', 'label', 'legend', 'li', 'map', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', 'p', 'param', 'pre', 'q', 's', 'samp', 'script', 'select', 'small', 'span', 'strike', 'strong', 'style', 'sub', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'title', 'tr', 'tt', 'u', 'ul', 'var' ] end def after_initialize(instance=nil, &blk) if blk after_initialize_parts << blk elsif instance if superclass.respond_to?(:after_initialize) superclass.after_initialize instance end after_initialize_parts.each do |part| instance.instance_eval &part end else raise ArgumentError, "You must provide either an instance or a block" end end protected def after_initialize_parts @after_initialize_parts ||= [] end end # Class method by which widget classes can declare that they need certain parameters. # If needed parameters are not passed in to #new, then an exception will be thrown # (with a hopefully useful message about which parameters are missing). This is intended # to catch silly bugs like passing in a parameter called 'name' to a widget that expects # a parameter called 'title'. Every variable declared in 'needs' will get an attr_reader # accessor declared for it. # # You can also declare default values for parameters using hash syntax. You can put #needs # declarations on multiple lines or on the same line; the only caveat is that if there are # default values, they all have to be at the end of the line (so they go into the magic # hash parameter). # # If a widget has no #needs declaration then it will accept any combination of parameters # (and make accessors for them) just like normal. In that case there will be no 'attr_reader's # declared. # If a widget wants to declare that it # takes no parameters, use the special incantation "needs nil" (and don't declare any other # needs, or kittens will cry). # # Usage: # class FancyForm < Erector::Widget # needs :title, :show_okay => true, :show_cancel => false # ... # end # # That means that # FancyForm.new(:title => 'Login') # will succeed, as will # FancyForm.new(:title => 'Login', :show_cancel => true) # but # FancyForm.new(:name => 'Login') # will fail. # def self.needs(*args) args.each do |arg| (@needs ||= []) << (arg.nil? ? nil : (arg.is_a? Hash) ? arg : arg.to_sym) end end protected def self.get_needs @needs ||= [] parent = self.ancestors[1] if parent.respond_to? :get_needs parent.get_needs + @needs else @needs end end public @@prettyprint_default = false def prettyprint_default @@prettyprint_default end def self.prettyprint_default=(enabled) @@prettyprint_default = enabled end NON_NEWLINEY = {'i' => true, 'b' => true, 'small' => true, 'img' => true, 'span' => true, 'a' => true, 'input' => true, 'textarea' => true, 'button' => true, 'select' => true } SPACES_PER_INDENT = 2 attr_reader :helpers, :assigns, :block, :parent, :output, :prettyprint, :indentation def initialize(assigns={}, &block) unless assigns.is_a? Hash raise "Erector's API has changed. Now you should pass only an options hash into Widget.new; the rest come in via to_s, or by using #widget." end if (respond_to? :render) && !self.method(:render).to_s.include?("(RailsWidget)") raise "Erector's API has changed. You should rename #{self.class}#render to #content." end @assigns = assigns assign_locals(assigns) @parent = block ? eval("self", block.binding) : nil @block = block self.class.after_initialize self end #-- methods for other classes to call, left public for ease of testing and documentation #++ protected def context(output, prettyprint = false, indentation = 0, helpers = nil) #TODO: pass in options hash, maybe, instead of parameters original_output = @output original_indendation = @indentation original_helpers = @helpers original_prettyprint = @prettyprint @output = output @at_start_of_line = true raise "indentation must be a number, not #{indentation.inspect}" unless indentation.is_a? Fixnum @indentation = indentation @helpers = helpers @prettyprint = prettyprint yield ensure @output = original_output @indentation = original_indendation @helpers = original_helpers @prettyprint = original_prettyprint end public def assign_locals(local_assigns) needed = self.class.get_needs.map{|need| need.is_a?(Hash) ? need.keys : need}.flatten assigned = [] local_assigns.each do |name, value| unless needed.empty? || needed.include?(name) raise "Unknown parameter '#{name}'. #{self.class.name} only accepts #{needed.join(', ')}" end assign_local(name, value) assigned << name end # set variables with default values self.class.get_needs.select{|var| var.is_a? Hash}.each do |hash| hash.each_pair do |name, value| unless assigned.include?(name) assign_local(name, value) assigned << name end end end missing = needed - assigned unless missing.empty? || missing == [nil] raise "Missing parameter#{missing.size == 1 ? '' : 's'}: #{missing.join(', ')}" end end def assign_local(name, value) instance_variable_set("@#{name}", value) if any_are_needed? metaclass.module_eval do attr_reader name end end end def any_are_needed? !self.class.get_needs.empty? end # Render (like to_s) but adding newlines and indentation. # This is a convenience method; you may just want to call to_s(:prettyprint => true) # so you can pass in other rendering options as well. def to_pretty to_s(:prettyprint => true) end # Entry point for rendering a widget (and all its children). This method creates a new output string (if necessary), # calls this widget's #content method and returns the string. # # Options: # output:: the string to output to. Default: a new empty string # prettyprint:: whether Erector should add newlines and indentation. Default: the value of prettyprint_default (which is false by default). # indentation:: the amount of spaces to indent. Ignored unless prettyprint is true. # helpers:: a helpers object containing utility methods. Usually this is a Rails view object. # content_method_name:: in case you want to call a method other than #content, pass its name in here. # # Note: Prettyprinting is an experimental feature and is subject to change # (either in terms of how it is enabled, or in terms of # what decisions Erector makes about where to add whitespace). def to_s(options = {}, &blk) raise "Erector::Widget#to_s now takes an options hash, not a symbol. Try calling \"to_s(:content_method_name=> :#{options})\"" if options.is_a? Symbol options = { :output => "", :prettyprint => prettyprint_default, :indentation => 0, :helpers => nil, :content_method_name => :content, }.merge(options) context(options[:output], options[:prettyprint], options[:indentation], options[:helpers]) do send(options[:content_method_name], &blk) output.to_s end end alias_method :inspect, :to_s # Template method which must be overridden by all widget subclasses. Inside this method you call the magic # #element methods which emit HTML and text to the output string. def content if @block instance_eval(&@block) end end # To call one widget from another, inside the parent widget's +content+ method, instantiate the child widget and call # its +write_via+ method, passing in +self+. This assures that the same output string # is used, which gives better performance than using +capture+ or +to_s+. # You can also use the +widget+ method. def write_via(parent) @parent = parent context(parent.output, parent.prettyprint, parent.indentation, parent.helpers) do content end end # Emits a (nested) widget onto the current widget's output stream. Accepts either # a class or an instance. If the first argument is a class, then the second argument # is a hash used to populate its instance variables. If the first argument is an # instance then the hash must be unspecified (or empty). # # The sub-widget will have access to the methods of the parent class, via some method_missing # magic and a "parent" pointer. def widget(target, assigns={}, &block) child = if target.is_a? Class target.new(assigns, &block) else unless assigns.empty? raise "Unexpected second parameter. Did you mean to pass in variables when you instantiated the #{target.class.to_s}?" end target end child.write_via(self) end # (Should we make this hidden?) def html_escape return to_s end #-- methods for subclasses to call #++ # Internal method used to emit an HTML/XML element, including an open tag, attributes (optional, via the default hash), # contents (also optional), and close tag. # # Using the arcane powers of Ruby, there are magic methods that call +element+ for all the standard # HTML tags, like +a+, +body+, +p+, and so forth. Look at the source of #full_tags for the full list. # Unfortunately, this big mojo confuses rdoc, so we can't see each method in this rdoc page, but trust # us, they're there. # # When calling one of these magic methods, put attributes in the default hash. If there is a string parameter, # then it is used as the contents. If there is a block, then it is executed (yielded), and the string parameter is ignored. # The block will usually be in the scope of the child widget, which means it has access to all the # methods of Widget, which will eventually end up appending text to the +output+ string. See how # elegant it is? Not confusing at all if you don't think about it. # def element(*args, &block) __element__(*args, &block) end # Internal method used to emit a self-closing HTML/XML element, including a tag name and optional attributes # (passed in via the default hash). # # Using the arcane powers of Ruby, there are magic methods that call +empty_element+ for all the standard # HTML tags, like +img+, +br+, and so forth. Look at the source of #empty_tags for the full list. # Unfortunately, this big mojo confuses rdoc, so we can't see each method in this rdoc page, but trust # us, they're there. # def empty_element(*args, &block) __empty_element__(*args, &block) end # Returns an HTML-escaped version of its parameter. Leaves the output string untouched. Note that # the #text method automatically HTML-escapes its parameter, so be careful *not* to do something like # text(h("2<4")) since that will double-escape the less-than sign (you'll get "2&lt;4" instead of # "2<4"). def h(content) content.html_escape end # Emits an open tag, comprising '<', tag name, optional attributes, and '>' def open_tag(tag_name, attributes={}) indent_for_open_tag(tag_name) @indentation += SPACES_PER_INDENT output.concat "<#{tag_name}#{format_attributes(attributes)}>" @at_start_of_line = false end # Emits text. If a string is passed in, it will be HTML-escaped. # If a widget or the result of calling methods such as raw # is passed in, the HTML will not be HTML-escaped again. # If another kind of object is passed in, the result of calling # its to_s method will be treated as a string would be. def text(value) if value.is_a? Widget widget value else output.concat(value.html_escape) end @at_start_of_line = false nil end # Returns text which will *not* be HTML-escaped. def raw(value) RawString.new(value.to_s) end # Emits text which will *not* be HTML-escaped. Same effect as text(raw(s)) def rawtext(value) text raw(value) end # Returns a copy of value with spaces replaced by non-breaking space characters. # With no arguments, return a single non-breaking space. # The output uses the escaping format ' ' since that works # in both HTML and XML (as opposed to ' ' which only works in HTML). def nbsp(value = " ") raw(value.html_escape.gsub(/ /,' ')) end # Return a character given its unicode code point or unicode name. def character(code_point_or_name) if code_point_or_name.is_a?(Symbol) found = Erector::CHARACTERS[code_point_or_name] if found.nil? raise "Unrecognized character #{code_point_or_name}" end raw("&#x#{sprintf '%x', found};") elsif code_point_or_name.is_a?(Integer) raw("&#x#{sprintf '%x', code_point_or_name};") else raise "Unrecognized argument to character: #{code_point_or_name}" end end # Emits a close tag, consisting of '<', tag name, and '>' def close_tag(tag_name) @indentation -= SPACES_PER_INDENT indent() output.concat("") if newliney(tag_name) _newline end end # Emits the result of joining the elements in array with the separator. # The array elements and separator can be Erector::Widget objects, # which are rendered, or strings, which are html-escaped and output. def join(array, separator) first = true array.each do |widget_or_text| if !first text separator end first = false text widget_or_text end end # Emits an XML instruction, which looks like this: def instruct(attributes={:version => "1.0", :encoding => "UTF-8"}) output.concat "" end # Creates a whole new output string, executes the block, then converts the output string to a string and # emits it as raw text. If at all possible you should avoid this method since it hurts performance, # and use +content+ or +write_via+ instead. def capture(&block) begin original_output = output @output = "" yield raw(output.to_s) ensure @output = original_output end end full_tags.each do |tag_name| self.class_eval( "def #{tag_name}(*args, &block)\n" << " __element__('#{tag_name}', *args, &block)\n" << "end", __FILE__, __LINE__ - 4 ) end empty_tags.each do |tag_name| self.class_eval( "def #{tag_name}(*args, &block)\n" << " __empty_element__('#{tag_name}', *args, &block)\n" << "end", __FILE__, __LINE__ - 4 ) end # Emits a javascript block inside a +script+ tag, wrapped in CDATA doohickeys like all the cool JS kids do. def javascript(*args, &block) if args.length > 2 raise ArgumentError, "Cannot accept more than two arguments" end attributes, value = nil, nil arg0 = args[0] if arg0.is_a?(Hash) attributes = arg0 else value = arg0 arg1 = args[1] if arg1.is_a?(Hash) attributes = arg1 end end attributes ||= {} attributes[:type] = "text/javascript" open_tag 'script', attributes # Shouldn't this be a "cdata" HtmlPart? # (maybe, but the syntax is specific to javascript; it isn't # really a generic XML CDATA section. Specifically, # ]]> within value is not treated as ending the # CDATA section by Firefox2 when parsing text/html, # although I guess we could refuse to generate ]]> # there, for the benefit of XML/XHTML parsers). rawtext "\n// \n" close_tag 'script' rawtext "\n" end # Convenience method to emit a css file link, which looks like this: # # The parameter is the full contents of the href attribute, including any ".css" extension. # # If you want to emit raw CSS inline, use the #style method instead. def css(href) link :rel => 'stylesheet', :type => 'text/css', :href => href end # Convenience method to emit an anchor tag whose href and text are the same, e.g. http://example.com def url(href) a href, :href => href end def newliney(tag_name) if @prettyprint !NON_NEWLINEY.include?(tag_name) else false end end ### internal utility methods protected # This is part of the sub-widget/parent feature (see #widget method). def method_missing(name, *args, &block) block ||= lambda {} # captures self HERE if @parent @parent.send(name, *args, &block) else super end end def __element__(tag_name, *args, &block) if args.length > 2 raise ArgumentError, "Cannot accept more than three arguments" end attributes, value = nil, nil arg0 = args[0] if arg0.is_a?(Hash) attributes = arg0 else value = arg0 arg1 = args[1] if arg1.is_a?(Hash) attributes = arg1 end end attributes ||= {} open_tag tag_name, attributes if block && value raise ArgumentError, "You can't pass both a block and a value to #{tag_name} -- please choose one." end if block instance_eval(&block) else text value end close_tag tag_name end def __empty_element__(tag_name, attributes={}) indent_for_open_tag(tag_name) output.concat "<#{tag_name}#{format_attributes(attributes)} />" if newliney(tag_name) _newline end end def _newline return unless @prettyprint output.concat "\n" @at_start_of_line = true end def indent_for_open_tag(tag_name) return unless @prettyprint if !@at_start_of_line && newliney(tag_name) _newline end indent() end def indent() if @at_start_of_line output.concat " " * @indentation end end def format_attributes(attributes) if !attributes || attributes.empty? "" else format_sorted(sorted(attributes)) end end def format_sorted(sorted) results = [''] sorted.each do |key, value| if value if value.is_a?(Array) value = [value].flatten.join(' ') end results << "#{key}=\"#{value.html_escape}\"" end end return results.join(' ') end def sorted(attributes) stringized = [] attributes.each do |key, value| stringized << [key.to_s, value] end return stringized.sort end def sort_for_xml_declaration(attributes) # correct order is "version, encoding, standalone" (XML 1.0 section 2.8). # But we only try to put version before encoding for now. stringized = [] attributes.each do |key, value| stringized << [key.to_s, value] end return stringized.sort{|a, b| b <=> a} end end end