govspeak 3.6.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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