govspeak 3.6.2 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 580a0caf181b2fdc0f51d15207150dba05668db6
4
- data.tar.gz: f745c4bdd7df05441c0e82f1b4cae4e9cc70af30
3
+ metadata.gz: 6e737eca62f51b5b954bc37b5fc55d2ba680bb8b
4
+ data.tar.gz: bf6f399b9badf4708ee7f4f9c6ca50a157493ac5
5
5
  SHA512:
6
- metadata.gz: 5a04e402f7e2224e20fb06ab180370b41addcd5036f56a93c012bb1b79fb9761bcdc35ebaf5c0f0d3457d91be8ad0455a2e9712578d5951b35238e6f89b6b654
7
- data.tar.gz: e47d74513a6d3f0a150fba13372f102b6399ed8ea3c5d0effcc28722f4a8cb001f6799ce0b4f4039d0b7d509049260d545eef89ee204e1ac454820353a26202b
6
+ metadata.gz: 9363e23283391abf19861ce9522b7cf30874aeecf37b7274dd19411a7539f1956ba3043218601fbc694acde33f2c062fc71f8bd1f113ce9884ca2e7938f1784f
7
+ data.tar.gz: efe6a7cda89d313ef06470fa0a5e71acc40d85b16ebe8ab95928865e4f20295e7663a4fa43a1e428a5edd2943a6949a96cbc9ec7c31bac28c2322f3a5ba5f353
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 4.0.0
2
+
3
+ * Drop support for Ruby 1.9.3
4
+ * Update Ruby to 2.3.1
5
+ * Adds support for the following items for feature parity with [whitehall](https://github.com/alphagov/whitehall):
6
+ * `{barchart}`
7
+ * `[embed:attachments:%content_id%]`
8
+ * `[embed:attachments:inline:%content_id%]`
9
+ * `[embed:link:%content_id%]`
10
+ * `[Contact:%content_id%]`
11
+ * Changes blockquote rendering to match whitehall [#81](https://github.com/alphagov/govspeak/pull/81)
12
+
13
+ ## 3.7.0
14
+
15
+ * Update Addressable version from 2.3.8 to 2.4.0
16
+
1
17
  ## 3.6.2
2
18
 
3
19
  * Fix bug with link parsing introduced in Kramdown 1.6.0 with the "inline attribute lists" feature which clashed with our monkey patch [#75](https://github.com/alphagov/govspeak/pull/75)
data/lib/govspeak.rb CHANGED
@@ -4,8 +4,13 @@ require 'govspeak/structured_header_extractor'
4
4
  require 'govspeak/html_validator'
5
5
  require 'govspeak/html_sanitizer'
6
6
  require 'govspeak/kramdown_overrides'
7
+ require 'govspeak/blockquote_extra_quote_remover'
8
+ require 'govspeak/post_processor'
7
9
  require 'kramdown/parser/kramdown_with_automatic_external_links'
8
10
  require 'htmlentities'
11
+ require 'presenters/attachment_presenter'
12
+ require 'presenters/h_card_presenter'
13
+ require 'erb'
9
14
 
10
15
  module Govspeak
11
16
 
@@ -17,6 +22,7 @@ module Govspeak
17
22
  @@extensions = []
18
23
 
19
24
  attr_accessor :images
25
+ attr_reader :attachments, :contacts, :links, :locale
20
26
 
21
27
  def self.to_html(source, options = {})
22
28
  new(source, options).to_html
@@ -25,22 +31,41 @@ module Govspeak
25
31
  def initialize(source, options = {})
26
32
  @source = source ? source.dup : ""
27
33
  @images = options.delete(:images) || []
28
- @options = {input: PARSER_CLASS_NAME, entity_output: :symbolic}.merge(options)
34
+ @attachments = Array(options.delete(:attachments))
35
+ @links = Array(options.delete(:links))
36
+ @contacts = Array(options.delete(:contacts))
37
+ @locale = options.fetch(:locale, "en")
38
+ @options = {input: PARSER_CLASS_NAME}.merge(options)
39
+ @options[:entity_output] = :symbolic
40
+ i18n_load_paths
29
41
  end
30
42
 
43
+ def i18n_load_paths
44
+ Dir.glob('locales/*.yml') do |f|
45
+ I18n.load_path << f
46
+ end
47
+ end
48
+ private :i18n_load_paths
49
+
31
50
  def kramdown_doc
32
51
  @kramdown_doc ||= Kramdown::Document.new(preprocess(@source), @options)
33
52
  end
34
53
  private :kramdown_doc
35
54
 
36
55
  def to_html
37
- kramdown_doc.to_html
56
+ @html ||= Govspeak::PostProcessor.process(kramdown_doc.to_html)
38
57
  end
39
58
 
40
59
  def to_liquid
41
60
  to_html
42
61
  end
43
62
 
63
+ def t(*args)
64
+ options = args.last.is_a?(Hash) ? args.last.dup : {}
65
+ key = args.shift
66
+ I18n.t(key, options.merge(locale: locale))
67
+ end
68
+
44
69
  def to_sanitized_html
45
70
  HtmlSanitizer.new(to_html).sanitize
46
71
  end
@@ -66,6 +91,7 @@ module Govspeak
66
91
  end
67
92
 
68
93
  def preprocess(source)
94
+ source = Govspeak::BlockquoteExtraQuoteRemover.remove(source)
69
95
  @@extensions.each do |title,regexp,block|
70
96
  source.gsub!(regexp) {
71
97
  instance_exec(*Regexp.last_match.captures, &block)
@@ -105,10 +131,6 @@ module Govspeak
105
131
  parser.new(body.strip).to_html.sub(/^<p>(.*)<\/p>$/,"<p><strong>\\1</strong></p>")
106
132
  end
107
133
 
108
- extension('reverse') { |body|
109
- body.reverse
110
- }
111
-
112
134
  extension('highlight-answer') { |body|
113
135
  %{\n\n<div class="highlight-answer">
114
136
  #{Govspeak::Document.new(body.strip).to_html}</div>\n}
@@ -137,6 +159,22 @@ module Govspeak
137
159
  %{\n\n<div role="note" aria-label="Help" class="application-notice help-notice">\n#{Govspeak::Document.new(body.strip).to_html}</div>\n}
138
160
  }
139
161
 
162
+ extension('barchart', /{barchart(.*?)}/) do |captures, body|
163
+ stacked = '.mc-stacked' if captures.include? 'stacked'
164
+ compact = '.compact' if captures.include? 'compact'
165
+ negative = '.mc-negative' if captures.include? 'negative'
166
+
167
+ [
168
+ '{:',
169
+ '.js-barchart-table',
170
+ stacked,
171
+ compact,
172
+ negative,
173
+ '.mc-auto-outdent',
174
+ '}'
175
+ ].join(' ')
176
+ end
177
+
140
178
  extension('attached-image', /^!!([0-9]+)/) do |image_number|
141
179
  image = images[image_number.to_i - 1]
142
180
  if image
@@ -147,6 +185,22 @@ module Govspeak
147
185
  end
148
186
  end
149
187
 
188
+ extension('attachment', /\[embed:attachments:([0-9a-f-]+)\]/) do |content_id, body|
189
+ attachment = attachments.detect { |a| a.content_id.match(content_id) }
190
+ next "" unless attachment
191
+ attachment = AttachmentPresenter.new(attachment)
192
+ content = File.read('lib/govspeak/extension/attachment.html.erb')
193
+ ERB.new(content).result(binding)
194
+ end
195
+
196
+ extension('attachment inline', /\[embed:attachments:inline:([0-9a-f-]+)\]/) do |content_id, body|
197
+ attachment = attachments.detect { |a| a.content_id.match(content_id) }
198
+ next "" unless attachment
199
+ attachment = AttachmentPresenter.new(attachment)
200
+ content = File.read('lib/govspeak/extension/inline_attachment.html.erb')
201
+ ERB.new(content).result(binding)
202
+ end
203
+
150
204
  def render_image(url, alt_text, caption = nil)
151
205
  lines = []
152
206
  lines << '<figure class="image embedded">'
@@ -217,5 +271,27 @@ module Govspeak
217
271
  end
218
272
  end
219
273
  end
274
+
275
+ extension('embed link', /\[embed:link:([0-9a-f-]+)\]/) do |content_id|
276
+ link = links.detect { |l| l.content_id.match(content_id) }
277
+ next "" unless link
278
+ if link.url
279
+ %Q{<a href="#{encode(link.url)}">#{encode(link.title)}</a>}
280
+ else
281
+ encode(link.title)
282
+ end
283
+ end
284
+
285
+ def render_hcard_address(contact)
286
+ HCardPresenter.from_contact(contact).render
287
+ end
288
+ private :render_hcard_address
289
+
290
+ extension('Contact', /\[Contact:([0-9a-f-]+)\]/) do |content_id|
291
+ contact = contacts.detect { |c| c.content_id.match(content_id) }
292
+ next "" unless contact
293
+ @renderer ||= ERB.new(File.read('lib/templates/contact.html.erb'))
294
+ @renderer.result(binding)
295
+ end
220
296
  end
221
297
  end
@@ -0,0 +1,19 @@
1
+ module Govspeak
2
+ module BlockquoteExtraQuoteRemover
3
+ QUOTE = '"\u201C\u201D\u201E\u201F\u2033\u2036'
4
+ LINE_BREAK = '\r\n?|\n'
5
+
6
+ # used to remove quotes from a markdown blockquote, as these will be inserted
7
+ # as part of the rendering
8
+ #
9
+ # for example:
10
+ # > "test"
11
+ #
12
+ # will be formatted to:
13
+ # > test
14
+ def self.remove(source)
15
+ return if source.nil?
16
+ source.gsub(/^>[ \t]*[#{QUOTE}]*([^ \t\n].+?)[#{QUOTE}]*[ \t]*(#{LINE_BREAK}?)$/, '> \1\2')
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,65 @@
1
+ <section class="attachment <%= attachment.section_class %>">
2
+ <div class="attachment-thumb">
3
+ <%= attachment.thumbnail_link %>
4
+ </div>
5
+ <div class="attachment-details">
6
+ <h2 class="title"><%= attachment.attachement_details %></h2>
7
+ <p class="metadata">
8
+ <% if attachment.references? %>
9
+ <span class="references">
10
+ <%= t('attachment.headings.reference') %>: <%= attachment.references %>
11
+ </span>
12
+ <% end %>
13
+ <% if attachment.unnumbered_paper? %>
14
+ <span class="unnumbered-paper">
15
+ <% if attachment.unnumbered_command_paper? %>
16
+ <%= t('attachment.headings.unnumbered_command_paper') %>
17
+ <% else %>
18
+ <%= t('attachment.headings.unnumbered_hoc_paper') %>
19
+ <% end %>
20
+ </span>
21
+ <% end %>
22
+ <% if attachment.previewable? %>
23
+ <span class="preview">
24
+ <strong>
25
+ <%= attachment.link("View online", attachment.preview_url) %>
26
+ </strong>
27
+ </span>
28
+ <span class="download">
29
+ attachment.download_link
30
+ </span>
31
+ <% else %>
32
+ <%= attachment.attachment_attributes %>
33
+ <% end %>
34
+ </p>
35
+ <% if attachment.order_url.present? %>
36
+ <p>
37
+ <%= attachment.link t('attachment.headings.order_a_copy'), attachment.order_url,
38
+ class: "order_url", title: t('attachment.headings.order_a_copy_full') %>
39
+ <% if attachment.price %>
40
+ (<span class="price"><%= attachment.price %></span>)
41
+ <% end %>
42
+ </p>
43
+ <% end %>
44
+
45
+ <% if attachment.opendocument? %>
46
+ <p class="opendocument-help">
47
+ <%= t('attachment.opendocument.help_html') %>
48
+ </p>
49
+ <% end %>
50
+
51
+ <% unless attachment.accessible? %>
52
+ <div data-module="toggle" class="accessibility-warning" id="<%= attachment.help_block_id %>">
53
+ <h2><%= t('attachment.accessibility.heading') %>
54
+ <a class="toggler" href="#<%= attachment.help_block_toggle_id %>" data-controls="<%= attachment.help_block_toggle_id %>" data-expanded="false"><%= t('attachment.accessibility.request_a_different_format') %></a>
55
+ </h2>
56
+ <p id="<%= attachment.help_block_toggle_id %>" class="js-hidden">
57
+ <%= t('attachment.accessibility.full_help_html',
58
+ email: attachment.alternative_format_order_link,
59
+ title: attachment.title,
60
+ references: attachment.references) %>
61
+ </p>
62
+ </div>
63
+ <% end %>
64
+ </div>
65
+ </section>
@@ -0,0 +1,4 @@
1
+ <span id="attachment_<%= attachment.id %>" class="attachment-inline">
2
+ <%= attachment.link attachment.title, attachment.url %>
3
+ (<%= attachment.attachment_attributes %>)
4
+ </span>
@@ -0,0 +1,42 @@
1
+ require 'nokogiri'
2
+
3
+ module Govspeak
4
+ class PostProcessor
5
+ attr_reader :input
6
+
7
+ @@extensions = []
8
+
9
+ def initialize(html)
10
+ @input = html
11
+ end
12
+
13
+ def nokogiri_document
14
+ doc = Nokogiri::HTML::Document.new
15
+ doc.encoding = "UTF-8"
16
+ doc.fragment(input)
17
+ end
18
+ private :nokogiri_document
19
+
20
+ def self.process(html)
21
+ new(html).output
22
+ end
23
+
24
+ def self.extension(title, &block)
25
+ @@extensions << [title, block]
26
+ end
27
+
28
+ def output
29
+ document = nokogiri_document
30
+ @@extensions.each do |_, block|
31
+ instance_exec(document, &block)
32
+ end
33
+ document.to_html
34
+ end
35
+
36
+ extension("add class to last p of blockquote") do |document|
37
+ document.css("blockquote p:last-child").map do |el|
38
+ el[:class] = "last-child"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,3 +1,3 @@
1
1
  module Govspeak
2
- VERSION = "3.6.2"
2
+ VERSION = "4.0.0"
3
3
  end
@@ -0,0 +1,241 @@
1
+ require "action_view"
2
+ require "money"
3
+
4
+ class AttachmentPresenter
5
+ attr_reader :attachment
6
+ include ActionView::Helpers::TagHelper
7
+ include ActionView::Helpers::NumberHelper
8
+ include ActionView::Helpers::AssetTagHelper
9
+
10
+ def initialize(attachment)
11
+ @attachment = attachment
12
+ end
13
+
14
+ def id
15
+ attachment.id
16
+ end
17
+
18
+ def order_url
19
+ attachment.order_url
20
+ end
21
+
22
+ def opendocument?
23
+ attachment.opendocument?
24
+ end
25
+
26
+ def url
27
+ attachment.url
28
+ end
29
+
30
+ def external?
31
+ attachment.external?
32
+ end
33
+
34
+ def price
35
+ return unless attachment.price
36
+ Money.from_amount(attachment.price, 'GBP').format
37
+ end
38
+
39
+ def accessible?
40
+ attachment.accessible?
41
+ end
42
+
43
+ def thumbnail_link
44
+ return if hide_thumbnail?
45
+ return if previewable?
46
+ link(attachment_thumbnail, url, "aria-hidden=true class=#{attachment_class}")
47
+ end
48
+
49
+ def help_block_toggle_id
50
+ "attachment-#{attachment.id}-accessibility-request"
51
+ end
52
+
53
+ def section_class
54
+ attachment.external? ? "hosted-externally" : "embedded"
55
+ end
56
+
57
+ def mail_to(email_address, name, options = {})
58
+ "<a href='mailto:#{email_address}?Subject=#{options[:subject]}&body=#{options[:body]}'>#{name}</a>"
59
+ end
60
+
61
+ def alternative_format_order_link
62
+ attachment_info = []
63
+ attachment_info << " Title: #{attachment.title}"
64
+ attachment_info << " Original format: #{attachment.file_extension}"
65
+ attachment_info << " ISBN: #{attachment.isbn}" if attachment.isbn.present?
66
+ attachment_info << " Unique reference: #{attachment.unique_reference}" if attachment.unique_reference.present?
67
+ attachment_info << " Command paper number: #{attachment.command_paper_number}" if attachment.command_paper_number.present?
68
+ if attachment.hoc_paper_number.present?
69
+ attachment_info << " House of Commons paper number: #{attachment.hoc_paper_number}"
70
+ attachment_info << " Parliamentary session: #{attachment.parliamentary_session}"
71
+ end
72
+
73
+ options = {
74
+ subject: "Request for '#{attachment.title}' in an alternative format",
75
+ body: body_for_mail(attachment_info)
76
+ }
77
+
78
+ mail_to(alternative_format_contact_email, alternative_format_contact_email, options)
79
+ end
80
+
81
+ def body_for_mail(attachment_info)
82
+ <<-END
83
+ Details of document required:
84
+
85
+ #{attachment_info.join("\n")}
86
+
87
+ Please tell us:
88
+
89
+ 1. What makes this format unsuitable for you?
90
+ 2. What format you would prefer?
91
+ END
92
+ end
93
+
94
+ def alternative_format_contact_email
95
+ "govuk-feedback@digital.cabinet-office.gov.uk"
96
+ end
97
+
98
+ def attachment_thumbnail
99
+ if attachment.pdf?
100
+ image_tag(attachment.file.thumbnail.url)
101
+ elsif attachment.html?
102
+ image_tag('pub-cover-html.png')
103
+ elsif %w{doc docx odt}.include? attachment.file_extension
104
+ image_tag('pub-cover-doc.png')
105
+ elsif %w{xls xlsx ods csv}.include? attachment.file_extension
106
+ image_tag('pub-cover-spreadsheet.png')
107
+ else
108
+ image_tag('pub-cover.png')
109
+ end
110
+ end
111
+
112
+ def references
113
+ references = []
114
+ references << "ISBN: #{attachment.isbn}" if attachment.isbn.present?
115
+ references << "Unique reference: #{attachment.unique_reference}" if attachment.unique_reference.present?
116
+ references << "Command paper number: #{attachment.command_paper_number}" if attachment.command_paper_number.present?
117
+ references << "HC: #{attachment.hoc_paper_number} #{attachment.parliamentary_session}" if attachment.hoc_paper_number.present?
118
+ prefix = references.size == 1 ? "and its reference" : "and its references"
119
+ references.any? ? ", #{prefix} (" + references.join(", ") + ")" : ""
120
+ end
121
+
122
+ def references?
123
+ !attachment.isbn.to_s.empty? || !attachment.unique_reference.to_s.empty? || !attachment.command_paper_number.to_s.empty? || !attachment.hoc_paper_number.to_s.empty?
124
+ end
125
+
126
+ def attachment_class
127
+ attachment.external? ? "hosted-externally" : "embedded"
128
+ end
129
+
130
+ def unnumbered_paper?
131
+ attachment.unnumbered_command_paper? || attachment.unnumbered_hoc_paper?
132
+ end
133
+
134
+ def unnumbered_command_paper?
135
+ attachment.unnumbered_command_paper?
136
+ end
137
+
138
+ def download_link
139
+ link(attachment.preview_url, "<strong>Download #{attachment.file_extension.upcase}</strong>", number_to_human_size(attachment.file_size))
140
+ end
141
+
142
+ def attachment_attributes
143
+ attributes = []
144
+ if attachment.html?
145
+ attributes << content_tag(:span, 'HTML', class: 'type')
146
+ elsif attachment.external?
147
+ attributes << content_tag(:span, url, class: 'url')
148
+ else
149
+ attributes << content_tag(:span, humanized_content_type(attachment.file_extension), class: 'type')
150
+ attributes << content_tag(:span, number_to_human_size(attachment.file_size), class: 'file-size')
151
+ attributes << content_tag(:span, pluralize(attachment.number_of_pages, "page") , class: 'page-length') if attachment.number_of_pages.present?
152
+ end
153
+ attributes.join(', ').html_safe
154
+ end
155
+
156
+ def preview_url
157
+ url << '/preview'
158
+ end
159
+
160
+ MS_WORD_DOCUMENT_HUMANIZED_CONTENT_TYPE = "MS Word Document"
161
+ MS_EXCEL_SPREADSHEET_HUMANIZED_CONTENT_TYPE = "MS Excel Spreadsheet"
162
+ MS_POWERPOINT_PRESENTATION_HUMANIZED_CONTENT_TYPE = "MS Powerpoint Presentation"
163
+
164
+ def file_abbr_tag(abbr, title)
165
+ content_tag(:abbr, abbr, title: title)
166
+ end
167
+
168
+ def humanized_content_type(file_extension)
169
+ file_extension_vs_humanized_content_type = {
170
+ "chm" => file_abbr_tag('CHM', 'Microsoft Compiled HTML Help'),
171
+ "csv" => file_abbr_tag('CSV', 'Comma-separated Values'),
172
+ "diff" => file_abbr_tag('DIFF', 'Plain text differences'),
173
+ "doc" => MS_WORD_DOCUMENT_HUMANIZED_CONTENT_TYPE,
174
+ "docx" => MS_WORD_DOCUMENT_HUMANIZED_CONTENT_TYPE,
175
+ "dot" => file_abbr_tag('DOT', 'MS Word Document Template'),
176
+ "dxf" => file_abbr_tag('DXF', 'AutoCAD Drawing Exchange Format'),
177
+ "eps" => file_abbr_tag('EPS', 'Encapsulated PostScript'),
178
+ "gif" => file_abbr_tag('GIF', 'Graphics Interchange Format'),
179
+ "gml" => file_abbr_tag('GML', 'Geography Markup Language'),
180
+ "html" => file_abbr_tag('HTML', 'Hypertext Markup Language'),
181
+ "ics" => file_abbr_tag('ICS', 'iCalendar file'),
182
+ "jpg" => "JPEG",
183
+ "odp" => file_abbr_tag('ODP', 'OpenDocument Presentation'),
184
+ "ods" => file_abbr_tag('ODS', 'OpenDocument Spreadsheet'),
185
+ "odt" => file_abbr_tag('ODT', 'OpenDocument Text document'),
186
+ "pdf" => file_abbr_tag('PDF', 'Portable Document Format'),
187
+ "png" => file_abbr_tag('PNG', 'Portable Network Graphic'),
188
+ "ppt" => MS_POWERPOINT_PRESENTATION_HUMANIZED_CONTENT_TYPE,
189
+ "pptx" => MS_POWERPOINT_PRESENTATION_HUMANIZED_CONTENT_TYPE,
190
+ "ps" => file_abbr_tag('PS', 'PostScript'),
191
+ "rdf" => file_abbr_tag('RDF', 'Resource Description Framework'),
192
+ "rtf" => file_abbr_tag('RTF', 'Rich Text Format'),
193
+ "sch" => file_abbr_tag('SCH', 'XML based Schematic'),
194
+ "txt" => "Plain text",
195
+ "wsdl" => file_abbr_tag('WSDL', 'Web Services Description Language'),
196
+ "xls" => MS_EXCEL_SPREADSHEET_HUMANIZED_CONTENT_TYPE,
197
+ "xlsm" => file_abbr_tag('XLSM', 'MS Excel Macro-Enabled Workbook'),
198
+ "xlsx" => MS_EXCEL_SPREADSHEET_HUMANIZED_CONTENT_TYPE,
199
+ "xlt" => file_abbr_tag('XLT', 'MS Excel Spreadsheet Template'),
200
+ "xsd" => file_abbr_tag('XSD', 'XML Schema'),
201
+ "xslt" => file_abbr_tag('XSLT', 'Extensible Stylesheet Language Transformation'),
202
+ "zip" => file_abbr_tag('ZIP', 'Zip archive'),
203
+ }
204
+ file_extension_vs_humanized_content_type.fetch(file_extension.to_s.downcase, '')
205
+ end
206
+
207
+ def previewable?
208
+ attachment.csv?
209
+ end
210
+
211
+ def title
212
+ attachment.title
213
+ end
214
+
215
+ def hide_thumbnail?
216
+ defined?(hide_thumbnail) && hide_thumbnail
217
+ end
218
+
219
+ def attachement_details
220
+ return if previewable?
221
+ link(attachment.title, url, title_link_options)
222
+ end
223
+
224
+ def title_link_options
225
+ title_link_options = ''
226
+ title_link_options << "rel=external" if attachment.external?
227
+ title_link_options << "aria-describedby=#{help_block_id}" unless attachment.accessible?
228
+ end
229
+
230
+ def help_block_id
231
+ "attachment-#{attachment.id}-accessibility-help"
232
+ end
233
+
234
+ def link(body, url, options={})
235
+ <<-END
236
+ <a href="#{url} #{options}">
237
+ #{body}
238
+ </a>
239
+ END
240
+ end
241
+ end