ronn 0.6.6 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +34 -0
- data/INSTALLING +18 -0
- data/README.md +43 -69
- data/Rakefile +9 -10
- data/bin/ronn +66 -49
- data/config.ru +1 -1
- data/lib/ronn.rb +35 -9
- data/lib/ronn/document.rb +239 -135
- data/lib/ronn/index.rb +183 -0
- data/lib/ronn/roff.rb +48 -28
- data/lib/ronn/template.rb +22 -8
- data/lib/ronn/template/dark.css +1 -4
- data/lib/ronn/template/default.html +0 -2
- data/lib/ronn/template/man.css +12 -12
- data/lib/ronn/utils.rb +8 -0
- data/man/index.html +78 -0
- data/man/index.txt +15 -0
- data/man/{ronn.5 → ronn-format.7} +26 -30
- data/man/{ronn.5.ronn → ronn-format.7.ronn} +39 -39
- data/man/ronn.1 +47 -15
- data/man/ronn.1.ronn +53 -23
- data/ronn.gemspec +14 -8
- data/test/angle_bracket_syntax.html +4 -2
- data/test/basic_document.html +4 -2
- data/test/custom_title_document.html +1 -2
- data/test/definition_list_syntax.html +4 -2
- data/test/dots_at_line_start_test.roff +10 -0
- data/test/dots_at_line_start_test.ronn +4 -0
- data/test/entity_encoding_test.html +24 -2
- data/test/entity_encoding_test.roff +41 -1
- data/test/entity_encoding_test.ronn +17 -0
- data/test/index.txt +8 -0
- data/test/markdown_syntax.html +5 -3
- data/test/markdown_syntax.roff +4 -4
- data/test/middle_paragraph.html +4 -2
- data/test/missing_spaces.roff +3 -0
- data/test/section_reference_links.html +4 -2
- data/test/{ronn_test.rb → test_ronn.rb} +18 -5
- data/test/{document_test.rb → test_ronn_document.rb} +59 -8
- data/test/test_ronn_index.rb +73 -0
- data/test/titleless_document.html +7 -2
- data/test/titleless_document.ronn +3 -2
- data/test/underline_spacing_test.roff +5 -0
- metadata +30 -14
- data/man/ronn.7 +0 -168
- data/man/ronn.7.ronn +0 -120
data/config.ru
CHANGED
data/lib/ronn.rb
CHANGED
@@ -3,22 +3,48 @@
|
|
3
3
|
# install standard UNIX roff(7) formatted manpages or to generate
|
4
4
|
# beautiful HTML manpages.
|
5
5
|
module Ronn
|
6
|
+
autoload :Document, 'ronn/document'
|
7
|
+
autoload :Index, 'ronn/index'
|
8
|
+
autoload :Template, 'ronn/template'
|
9
|
+
autoload :Roff, 'ronn/roff'
|
10
|
+
autoload :Server, 'ronn/server'
|
11
|
+
|
6
12
|
# Create a new Ronn::Document for the given ronn file. See
|
7
13
|
# Ronn::Document.new for usage information.
|
8
14
|
def self.new(filename, attributes={}, &block)
|
9
15
|
Document.new(filename, attributes, &block)
|
10
16
|
end
|
11
17
|
|
12
|
-
#
|
13
|
-
REV = '0.6-6-gd7645f2'
|
14
|
-
VERSION = REV[/(?:[\d.]+)(?:-\d+)?/].tr('-', '.')
|
15
|
-
|
18
|
+
# truthy when this a release (\d.\d.\d) version.
|
16
19
|
def self.release?
|
17
|
-
|
20
|
+
revision != '' && !revision.include?('-')
|
18
21
|
end
|
19
22
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
23
|
+
# version: 0.6.11
|
24
|
+
#
|
25
|
+
# A semantic version number based on the git revision. The third element
|
26
|
+
# of the version is incremented by the commit offset, such that version
|
27
|
+
# 0.6.6-5-gdacd74b => 0.6.11
|
28
|
+
def self.version
|
29
|
+
ver = revision[/^[0-9.-]+/].split(/[.-]/).map { |p| p.to_i }
|
30
|
+
ver[2] += ver.pop while ver.size > 3
|
31
|
+
ver.join('.')
|
32
|
+
end
|
33
|
+
|
34
|
+
# revision: 0.6.6-5-gdacd74b
|
35
|
+
# revision: 0.6.25
|
36
|
+
#
|
37
|
+
# The string revision as reported by: git-describe --tags. This is just the
|
38
|
+
# tag name when a tag references the HEAD commit (0.6.25). When the HEAD
|
39
|
+
# commit is not tagged, this is a "<tag>-<offset>-<sha1>" string:
|
40
|
+
# <tag> - closest tag name
|
41
|
+
# <offset> - number of commits ahead of <tag>
|
42
|
+
# <sha1> - 7c short SHA1 for HEAD
|
43
|
+
def self.revision
|
44
|
+
REV
|
45
|
+
end
|
46
|
+
|
47
|
+
# value generated by: rake rev
|
48
|
+
REV = '0.7.0'
|
49
|
+
VERSION = version
|
24
50
|
end
|
data/lib/ronn/document.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
require '
|
1
|
+
require 'time'
|
2
2
|
require 'cgi'
|
3
3
|
require 'hpricot'
|
4
4
|
require 'rdiscount'
|
@@ -18,7 +18,15 @@ module Ronn
|
|
18
18
|
class Document
|
19
19
|
include Ronn::Utils
|
20
20
|
|
21
|
-
|
21
|
+
# Path to the Ronn document. This may be '-' or nil when the Ronn::Document
|
22
|
+
# object is created with a stream.
|
23
|
+
attr_reader :path
|
24
|
+
|
25
|
+
# The raw input data, read from path or stream and unmodified.
|
26
|
+
attr_reader :data
|
27
|
+
|
28
|
+
# The index used to resolve man and file references.
|
29
|
+
attr_accessor :index
|
22
30
|
|
23
31
|
# The man pages name: usually a single word name of
|
24
32
|
# a program or filename; displayed along with the section in
|
@@ -56,12 +64,22 @@ module Ronn
|
|
56
64
|
def initialize(path=nil, attributes={}, &block)
|
57
65
|
@path = path
|
58
66
|
@basename = path.to_s =~ /^-?$/ ? nil : File.basename(path)
|
59
|
-
@reader = block ||
|
67
|
+
@reader = block ||
|
68
|
+
lambda do |f|
|
69
|
+
if ['-', nil].include?(f)
|
70
|
+
STDIN.read
|
71
|
+
else
|
72
|
+
File.read(f)
|
73
|
+
end
|
74
|
+
end
|
60
75
|
@data = @reader.call(path)
|
61
|
-
@name, @section, @tagline =
|
62
|
-
|
63
|
-
@fragment = preprocess
|
76
|
+
@name, @section, @tagline = sniff
|
77
|
+
|
64
78
|
@styles = %w[man]
|
79
|
+
@manual, @organization, @date = nil
|
80
|
+
@markdown, @input_html, @html = nil
|
81
|
+
@index = Ronn::Index[path || '.']
|
82
|
+
@index.add_manual(self) if path && name
|
65
83
|
|
66
84
|
attributes.each { |attr_name,value| send("#{attr_name}=", value) }
|
67
85
|
end
|
@@ -109,7 +127,7 @@ module Ronn
|
|
109
127
|
# Truthful when the name was extracted from the name section
|
110
128
|
# of the document.
|
111
129
|
def name?
|
112
|
-
|
130
|
+
!@name.nil?
|
113
131
|
end
|
114
132
|
|
115
133
|
# Returns the manual page section based first on the document's
|
@@ -121,7 +139,25 @@ module Ronn
|
|
121
139
|
# True when the section number was extracted from the name
|
122
140
|
# section of the document.
|
123
141
|
def section?
|
124
|
-
|
142
|
+
!@section.nil?
|
143
|
+
end
|
144
|
+
|
145
|
+
# The name used to reference this manual.
|
146
|
+
def reference_name
|
147
|
+
name + (section && "(#{section})").to_s
|
148
|
+
end
|
149
|
+
|
150
|
+
# Truthful when the document started with an h1 but did not follow
|
151
|
+
# the "<name>(<sect>) -- <tagline>" convention. We assume this is some kind
|
152
|
+
# of custom title.
|
153
|
+
def title?
|
154
|
+
!name? && tagline
|
155
|
+
end
|
156
|
+
|
157
|
+
# The document's title when no name section was defined. When a name section
|
158
|
+
# exists, this value is nil.
|
159
|
+
def title
|
160
|
+
@tagline if !name?
|
125
161
|
end
|
126
162
|
|
127
163
|
# The date the man page was published. If not set explicitly,
|
@@ -136,11 +172,11 @@ module Ronn
|
|
136
172
|
# Retrieve a list of top-level section headings in the document and return
|
137
173
|
# as an array of +[id, text]+ tuples, where +id+ is the element's generated
|
138
174
|
# id and +text+ is the inner text of the heading element.
|
139
|
-
def
|
140
|
-
|
141
|
-
[
|
142
|
-
end
|
175
|
+
def toc
|
176
|
+
@toc ||=
|
177
|
+
html.search('h2[@id]').map { |h2| [h2.attributes['id'], h2.inner_text] }
|
143
178
|
end
|
179
|
+
alias section_heads toc
|
144
180
|
|
145
181
|
# Styles to insert in the generated HTML output. This is a simple Array of
|
146
182
|
# string module names or file paths.
|
@@ -148,6 +184,37 @@ module Ronn
|
|
148
184
|
@styles = (%w[man] + styles).uniq
|
149
185
|
end
|
150
186
|
|
187
|
+
# Sniff the document header and extract basic document metadata. Return a
|
188
|
+
# tuple of the form: [name, section, description], where missing information
|
189
|
+
# is represented by nil and any element may be missing.
|
190
|
+
def sniff
|
191
|
+
html = Markdown.new(data[0, 512]).to_html
|
192
|
+
heading, html = html.split("</h1>\n", 2)
|
193
|
+
return [nil, nil, nil] if html.nil?
|
194
|
+
|
195
|
+
case heading
|
196
|
+
when /([\w_.\[\]~+=@:-]+)\s*\((\d\w*)\)\s*-+\s*(.*)/
|
197
|
+
# name(section) -- description
|
198
|
+
[$1, $2, $3]
|
199
|
+
when /([\w_.\[\]~+=@:-]+)\s+-+\s+(.*)/
|
200
|
+
# name -- description
|
201
|
+
[$1, nil, $2]
|
202
|
+
else
|
203
|
+
# description
|
204
|
+
[nil, nil, heading.sub('<h1>', '')]
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Preprocessed markdown input text.
|
209
|
+
def markdown
|
210
|
+
@markdown ||= process_markdown!
|
211
|
+
end
|
212
|
+
|
213
|
+
# A Hpricot::Document for the manual content fragment.
|
214
|
+
def html
|
215
|
+
@html ||= process_html!
|
216
|
+
end
|
217
|
+
|
151
218
|
# Convert the document to :roff, :html, or :html_fragment and
|
152
219
|
# return the result as a string.
|
153
220
|
def convert(format)
|
@@ -158,12 +225,8 @@ module Ronn
|
|
158
225
|
def to_roff
|
159
226
|
RoffFilter.new(
|
160
227
|
to_html_fragment(wrap_class=nil),
|
161
|
-
name,
|
162
|
-
|
163
|
-
tagline,
|
164
|
-
manual,
|
165
|
-
organization,
|
166
|
-
date
|
228
|
+
name, section, tagline,
|
229
|
+
manual, organization, date
|
167
230
|
).to_s
|
168
231
|
end
|
169
232
|
|
@@ -177,6 +240,7 @@ module Ronn
|
|
177
240
|
end
|
178
241
|
|
179
242
|
template = Ronn::Template.new(self)
|
243
|
+
template.context.push :html => to_html_fragment(wrap_class=nil)
|
180
244
|
template.render(layout_path || 'default')
|
181
245
|
end
|
182
246
|
|
@@ -184,71 +248,127 @@ module Ronn
|
|
184
248
|
# as a string. The HTML does not include <html>, <head>,
|
185
249
|
# or <style> tags.
|
186
250
|
def to_html_fragment(wrap_class='mp')
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
251
|
+
return html.to_s if wrap_class.nil?
|
252
|
+
[
|
253
|
+
"<div class='#{wrap_class}'>",
|
254
|
+
html.to_s,
|
255
|
+
"</div>"
|
256
|
+
].join("\n")
|
257
|
+
end
|
258
|
+
|
259
|
+
def to_markdown
|
260
|
+
markdown
|
261
|
+
end
|
262
|
+
|
263
|
+
def to_h
|
264
|
+
%w[name section tagline manual organization date styles toc].
|
265
|
+
inject({}) { |hash, name| hash[name] = send(name); hash }
|
266
|
+
end
|
267
|
+
|
268
|
+
def to_yaml
|
269
|
+
require 'yaml'
|
270
|
+
to_h.to_yaml
|
271
|
+
end
|
272
|
+
|
273
|
+
def to_json
|
274
|
+
require 'json'
|
275
|
+
to_h.merge('date' => date.iso8601).to_json
|
199
276
|
end
|
200
277
|
|
201
278
|
protected
|
202
|
-
|
203
|
-
|
279
|
+
##
|
280
|
+
# Document Processing
|
281
|
+
|
282
|
+
# Parse the document and extract the name, section, and tagline from its
|
283
|
+
# contents. This is called while the object is being initialized.
|
284
|
+
def preprocess!
|
285
|
+
input_html
|
286
|
+
nil
|
287
|
+
end
|
204
288
|
|
205
|
-
|
206
|
-
|
207
|
-
# initialized.
|
208
|
-
def preprocess
|
209
|
-
[
|
210
|
-
:heading_anchor_pre_filter,
|
211
|
-
:angle_quote_pre_filter,
|
212
|
-
:markdown_filter,
|
213
|
-
:angle_quote_post_filter,
|
214
|
-
:definition_list_filter,
|
215
|
-
:heading_anchor_filter,
|
216
|
-
:annotate_bare_links_filter
|
217
|
-
].inject(data) { |res,filter| send(filter, res) }
|
289
|
+
def input_html
|
290
|
+
@input_html ||= strip_heading(Markdown.new(markdown).to_html)
|
218
291
|
end
|
219
292
|
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
doc.search('a[@href]').each do |node|
|
225
|
-
href = node.attributes['href']
|
226
|
-
text = node.inner_text
|
293
|
+
def strip_heading(html)
|
294
|
+
heading, html = html.split("</h1>\n", 2)
|
295
|
+
html || heading
|
296
|
+
end
|
227
297
|
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
298
|
+
def process_markdown!
|
299
|
+
markdown = markdown_filter_heading_anchors(self.data)
|
300
|
+
markdown_filter_link_index(markdown)
|
301
|
+
markdown_filter_angle_quotes(markdown)
|
302
|
+
end
|
303
|
+
|
304
|
+
def process_html!
|
305
|
+
@html = Hpricot(input_html)
|
306
|
+
html_filter_angle_quotes
|
307
|
+
html_filter_definition_lists
|
308
|
+
html_filter_inject_name_section
|
309
|
+
html_filter_heading_anchors
|
310
|
+
html_filter_annotate_bare_links
|
311
|
+
html_filter_manual_reference_links
|
312
|
+
@html
|
313
|
+
end
|
314
|
+
|
315
|
+
##
|
316
|
+
# Filters
|
317
|
+
|
318
|
+
# Appends all index links to the end of the document as Markdown reference
|
319
|
+
# links. This lets us use [foo(3)][] syntax to link to index entries.
|
320
|
+
def markdown_filter_link_index(markdown)
|
321
|
+
return markdown if index.nil? || index.empty?
|
322
|
+
markdown << "\n\n"
|
323
|
+
index.each { |ref| markdown << "[#{ref.name}]: #{ref.url}\n" }
|
324
|
+
end
|
325
|
+
|
326
|
+
# Add [id]: #ANCHOR elements to the markdown source text for all sections.
|
327
|
+
# This lets us use the [SECTION-REF][] syntax
|
328
|
+
def markdown_filter_heading_anchors(markdown)
|
329
|
+
first = true
|
330
|
+
markdown.split("\n").grep(/^[#]{2,5} +[\w '-]+[# ]*$/).each do |line|
|
331
|
+
markdown << "\n\n" if first
|
332
|
+
first = false
|
333
|
+
title = line.gsub(/[^\w -]/, '').strip
|
334
|
+
anchor = title.gsub(/\W+/, '-').gsub(/(^-+|-+$)/, '')
|
335
|
+
markdown << "[#{title}]: ##{anchor} \"#{title}\"\n"
|
336
|
+
end
|
337
|
+
markdown
|
338
|
+
end
|
339
|
+
|
340
|
+
# Convert <WORD> to <var>WORD</var> but only if WORD isn't an HTML tag.
|
341
|
+
def markdown_filter_angle_quotes(markdown)
|
342
|
+
markdown.gsub(/\<([^:.\/]+?)\>/) do |match|
|
343
|
+
contents = $1
|
344
|
+
tag, attrs = contents.split(' ', 2)
|
345
|
+
if attrs =~ /\/=/ || html_element?(tag.sub(/^\//, '')) ||
|
346
|
+
data.include?("</#{tag}>")
|
347
|
+
match.to_s
|
348
|
+
else
|
349
|
+
"<var>#{contents}</var>"
|
233
350
|
end
|
234
351
|
end
|
235
|
-
doc
|
236
352
|
end
|
237
353
|
|
238
|
-
#
|
239
|
-
def
|
240
|
-
|
241
|
-
|
242
|
-
|
354
|
+
# Perform angle quote (<THESE>) post filtering.
|
355
|
+
def html_filter_angle_quotes
|
356
|
+
# convert all angle quote vars nested in code blocks
|
357
|
+
# back to the original text
|
358
|
+
@html.search('code').search('text()').each do |node|
|
359
|
+
next unless node.to_html.include?('var>')
|
360
|
+
new =
|
361
|
+
node.to_html.
|
362
|
+
gsub('<var>', '<').
|
363
|
+
gsub("</var>", '>')
|
364
|
+
node.swap(new)
|
243
365
|
end
|
244
|
-
doc
|
245
366
|
end
|
246
367
|
|
247
368
|
# Convert special format unordered lists to definition lists.
|
248
|
-
def
|
249
|
-
doc = parse_html(html)
|
369
|
+
def html_filter_definition_lists
|
250
370
|
# process all unordered lists depth-first
|
251
|
-
|
371
|
+
@html.search('ul').to_a.reverse.each do |ul|
|
252
372
|
items = ul.search('li')
|
253
373
|
next if items.any? { |item| item.inner_text.split("\n", 2).first !~ /:$/ }
|
254
374
|
|
@@ -270,85 +390,69 @@ module Ronn
|
|
270
390
|
container.swap(wrap.sub(/></, ">#{definition}<"))
|
271
391
|
end
|
272
392
|
end
|
273
|
-
doc
|
274
393
|
end
|
275
394
|
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
# Run markdown on the data and extract name, section, and
|
293
|
-
# tagline.
|
294
|
-
def markdown_filter(data)
|
295
|
-
@markdown = data
|
296
|
-
html = Markdown.new(data).to_html
|
297
|
-
@tagline, html = html.split("</h1>\n", 2)
|
298
|
-
if html.nil?
|
299
|
-
html = @tagline
|
300
|
-
@tagline = nil
|
301
|
-
else
|
302
|
-
# grab name and section from title
|
303
|
-
@tagline.sub!('<h1>', '')
|
304
|
-
if @tagline =~ /([\w_.\[\]~+=@:-]+)\s*\((\d\w*)\)\s*-+\s*(.*)/
|
305
|
-
@name = $1
|
306
|
-
@section = $2
|
307
|
-
@tagline = $3
|
308
|
-
elsif @tagline =~ /([\w_.\[\]~+=@:-]+)\s+-+\s+(.*)/
|
309
|
-
@name = $1
|
310
|
-
@tagline = $2
|
395
|
+
def html_filter_inject_name_section
|
396
|
+
markup =
|
397
|
+
if title?
|
398
|
+
"<h1>#{title}</h1>"
|
399
|
+
elsif name
|
400
|
+
"<h2>NAME</h2>\n" +
|
401
|
+
"<p class='man-name'>\n <code>#{name}</code>" +
|
402
|
+
(tagline ? " - <span class='man-whatis'>#{tagline}</span>\n" : "\n") +
|
403
|
+
"</p>\n"
|
404
|
+
end
|
405
|
+
if markup
|
406
|
+
if @html.children
|
407
|
+
@html.at("*").before(markup)
|
408
|
+
else
|
409
|
+
@html = Hpricot(markup)
|
311
410
|
end
|
312
411
|
end
|
313
|
-
|
314
|
-
html.to_s
|
315
412
|
end
|
316
413
|
|
317
|
-
#
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
contents = $1
|
322
|
-
tag, attrs = contents.split(' ', 2)
|
323
|
-
if attrs =~ /\/=/ ||
|
324
|
-
html_element?(tag.sub(/^\//, '')) ||
|
325
|
-
data.include?("</#{tag}>")
|
326
|
-
match.to_s
|
327
|
-
else
|
328
|
-
"<var>#{contents}</var>"
|
329
|
-
end
|
414
|
+
# Add URL anchors to all HTML heading elements.
|
415
|
+
def html_filter_heading_anchors
|
416
|
+
@html.search('h2|h3|h4|h5|h6').not('[@id]').each do |heading|
|
417
|
+
heading.set_attribute('id', heading.inner_text.gsub(/\W+/, '-'))
|
330
418
|
end
|
331
419
|
end
|
332
420
|
|
333
|
-
# Add
|
334
|
-
#
|
335
|
-
def
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
421
|
+
# Add a 'data-bare-link' attribute to hyperlinks
|
422
|
+
# whose text labels are the same as their href URLs.
|
423
|
+
def html_filter_annotate_bare_links
|
424
|
+
@html.search('a[@href]').each do |node|
|
425
|
+
href = node.attributes['href']
|
426
|
+
text = node.inner_text
|
427
|
+
|
428
|
+
if href == text ||
|
429
|
+
href[0] == ?# ||
|
430
|
+
CGI.unescapeHTML(href) == "mailto:#{CGI.unescapeHTML(text)}"
|
431
|
+
then
|
432
|
+
node.set_attribute('data-bare-link', 'true')
|
433
|
+
end
|
343
434
|
end
|
344
|
-
data
|
345
435
|
end
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
436
|
+
|
437
|
+
# Convert text of the form "name(section)" to a hyperlink. The URL is
|
438
|
+
# obtaiend from the index.
|
439
|
+
def html_filter_manual_reference_links
|
440
|
+
return if index.nil?
|
441
|
+
@html.search('text()').each do |node|
|
442
|
+
next if !node.content.include?(')')
|
443
|
+
next if %w[pre code h1 h2 h3].include?(node.parent.name)
|
444
|
+
next if child_of?(node, 'a')
|
445
|
+
node.swap(
|
446
|
+
node.content.gsub(/([0-9A-Za-z_:.+=@~-]+)(\(\d+\w*\))/) {
|
447
|
+
name, sect = $1, $2
|
448
|
+
if ref = index["#{name}#{sect}"]
|
449
|
+
"<a class='man-ref' href='#{ref.url}'>#{name}<span class='s'>#{sect}</span></a>"
|
450
|
+
else
|
451
|
+
# warn "warn: manual reference not defined: '#{name}#{sect}'"
|
452
|
+
"<span class='man-ref'>#{name}<span class='s'>#{sect}</span></span>"
|
453
|
+
end
|
454
|
+
}
|
455
|
+
)
|
352
456
|
end
|
353
457
|
end
|
354
458
|
end
|