nronn 0.10.1.pre2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (96) hide show
  1. checksums.yaml +7 -0
  2. data/AUTHORS +8 -0
  3. data/CHANGES +230 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +72 -0
  6. data/INSTALLING.md +92 -0
  7. data/LICENSE.txt +12 -0
  8. data/README.md +131 -0
  9. data/Rakefile +153 -0
  10. data/bin/ronn +253 -0
  11. data/completion/bash/ronn +32 -0
  12. data/completion/zsh/_ronn +24 -0
  13. data/config.ru +15 -0
  14. data/lib/ronn/document.rb +530 -0
  15. data/lib/ronn/index.rb +180 -0
  16. data/lib/ronn/roff.rb +393 -0
  17. data/lib/ronn/server.rb +67 -0
  18. data/lib/ronn/template/80c.css +6 -0
  19. data/lib/ronn/template/dark.css +18 -0
  20. data/lib/ronn/template/darktoc.css +17 -0
  21. data/lib/ronn/template/default.html +41 -0
  22. data/lib/ronn/template/man.css +100 -0
  23. data/lib/ronn/template/print.css +5 -0
  24. data/lib/ronn/template/screen.css +105 -0
  25. data/lib/ronn/template/toc.css +27 -0
  26. data/lib/ronn/template.rb +173 -0
  27. data/lib/ronn/utils.rb +57 -0
  28. data/lib/ronn.rb +47 -0
  29. data/man/index.html +78 -0
  30. data/man/index.txt +15 -0
  31. data/man/ronn-format.7 +145 -0
  32. data/man/ronn-format.7.ronn +157 -0
  33. data/man/ronn.1 +227 -0
  34. data/man/ronn.1.ronn +316 -0
  35. data/nronn.gemspec +136 -0
  36. data/test/angle_bracket_syntax.html +27 -0
  37. data/test/angle_bracket_syntax.roff +24 -0
  38. data/test/angle_bracket_syntax.ronn +22 -0
  39. data/test/backticks.html +14 -0
  40. data/test/backticks.ronn +10 -0
  41. data/test/basic_document.html +8 -0
  42. data/test/basic_document.ronn +4 -0
  43. data/test/circumflexes.ronn +1 -0
  44. data/test/code_blocks.html +38 -0
  45. data/test/code_blocks.roff +38 -0
  46. data/test/code_blocks.ronn +41 -0
  47. data/test/code_blocks_regression +19 -0
  48. data/test/code_blocks_regression.html +38 -0
  49. data/test/code_blocks_regression.ronn +40 -0
  50. data/test/contest.rb +70 -0
  51. data/test/custom_title_document.html +6 -0
  52. data/test/custom_title_document.ronn +5 -0
  53. data/test/definition_list_syntax.html +25 -0
  54. data/test/definition_list_syntax.roff +19 -0
  55. data/test/definition_list_syntax.ronn +18 -0
  56. data/test/dots_at_line_start_test.roff +19 -0
  57. data/test/dots_at_line_start_test.ronn +12 -0
  58. data/test/ellipses.roff +7 -0
  59. data/test/ellipses.ronn +7 -0
  60. data/test/entity_encoding_test.html +42 -0
  61. data/test/entity_encoding_test.roff +51 -0
  62. data/test/entity_encoding_test.ronn +34 -0
  63. data/test/index.txt +8 -0
  64. data/test/markdown_syntax.html +954 -0
  65. data/test/markdown_syntax.roff +907 -0
  66. data/test/markdown_syntax.ronn +881 -0
  67. data/test/middle_paragraph.html +14 -0
  68. data/test/middle_paragraph.roff +9 -0
  69. data/test/middle_paragraph.ronn +10 -0
  70. data/test/missing_spaces.roff +7 -0
  71. data/test/missing_spaces.ronn +2 -0
  72. data/test/nested_list.ronn +19 -0
  73. data/test/nested_list_with_code.html +14 -0
  74. data/test/nested_list_with_code.roff +11 -0
  75. data/test/nested_list_with_code.ronn +6 -0
  76. data/test/ordered_list.html +28 -0
  77. data/test/ordered_list.roff +25 -0
  78. data/test/ordered_list.ronn +21 -0
  79. data/test/page.with.periods.in.name.5.ronn +4 -0
  80. data/test/pre_block_with_quotes.roff +8 -0
  81. data/test/pre_block_with_quotes.ronn +6 -0
  82. data/test/section_reference_links.html +16 -0
  83. data/test/section_reference_links.roff +7 -0
  84. data/test/section_reference_links.ronn +12 -0
  85. data/test/single_quotes.html +11 -0
  86. data/test/single_quotes.roff +5 -0
  87. data/test/single_quotes.ronn +9 -0
  88. data/test/tables.ronn +24 -0
  89. data/test/test_ronn.rb +124 -0
  90. data/test/test_ronn_document.rb +186 -0
  91. data/test/test_ronn_index.rb +73 -0
  92. data/test/titleless_document.html +9 -0
  93. data/test/titleless_document.ronn +3 -0
  94. data/test/underline_spacing_test.roff +13 -0
  95. data/test/underline_spacing_test.ronn +11 -0
  96. metadata +309 -0
@@ -0,0 +1,530 @@
1
+ require 'time'
2
+ require 'cgi'
3
+ require 'nokogiri'
4
+ require 'kramdown'
5
+ require 'ronn/index'
6
+ require 'ronn/roff'
7
+ require 'ronn/template'
8
+ require 'ronn/utils'
9
+
10
+ module Ronn
11
+ # The Document class can be used to load and inspect a ronn document
12
+ # and to convert a ronn document into other formats, like roff or
13
+ # HTML.
14
+ #
15
+ # Ronn files may optionally follow the naming convention:
16
+ # "<name>.<section>.ronn". The <name> and <section> are used in
17
+ # generated documentation unless overridden by the information
18
+ # extracted from the document's name section.
19
+ class Document
20
+ include Ronn::Utils
21
+
22
+ # Path to the Ronn document. This may be '-' or nil when the Ronn::Document
23
+ # object is created with a stream, in which case stdin will be read.
24
+ attr_reader :path
25
+
26
+ # Encoding that the Ronn document is in
27
+ attr_accessor :encoding
28
+
29
+ # The raw input data, read from path or stream and unmodified.
30
+ attr_reader :data
31
+
32
+ # The index used to resolve man and file references.
33
+ attr_accessor :index
34
+
35
+ # The man pages name: usually a single word name of
36
+ # a program or filename; displayed along with the section in
37
+ # the left and right portions of the header as well as the bottom
38
+ # right section of the footer.
39
+ attr_writer :name
40
+
41
+ # The man page's section: a string whose first character
42
+ # is numeric; displayed in parenthesis along with the name.
43
+ attr_writer :section
44
+
45
+ # Single sentence description of the thing being described
46
+ # by this man page; displayed in the NAME section.
47
+ attr_accessor :tagline
48
+
49
+ # The manual this document belongs to; center displayed in
50
+ # the header.
51
+ attr_accessor :manual
52
+
53
+ # The name of the group, organization, or individual responsible
54
+ # for this document; displayed in the left portion of the footer.
55
+ attr_accessor :organization
56
+
57
+ # The date the document was published; center displayed in
58
+ # the document footer.
59
+ attr_writer :date
60
+
61
+ # Array of style modules to apply to the document.
62
+ attr_reader :styles
63
+
64
+ # Output directory to write files to.
65
+ attr_accessor :outdir
66
+
67
+ # Create a Ronn::Document given a path or with the data returned by
68
+ # calling the block. The document is loaded and preprocessed before
69
+ # the intialize method returns. The attributes hash may contain values
70
+ # for any writeable attributes defined on this class.
71
+ def initialize(path = nil, attributes = {}, &block)
72
+ @path = path
73
+ @basename = path.to_s =~ /^-?$/ ? nil : File.basename(path)
74
+ @reader = block ||
75
+ lambda do |f|
76
+ if ['-', nil].include?(f)
77
+ $stdin.read
78
+ else
79
+ File.read(f, encoding: @encoding)
80
+ end
81
+ end
82
+ @data = @reader.call(path)
83
+ @name, @section, @tagline = sniff
84
+
85
+ @styles = %w[man]
86
+ @manual, @organization, @date = nil
87
+ @markdown, @input_html, @html = nil
88
+ @index = Ronn::Index[path || '.']
89
+ @index.add_manual(self) if path && name
90
+
91
+ attributes.each { |attr_name, value| send("#{attr_name}=", value) }
92
+ end
93
+
94
+ # Generate a file basename of the form "<name>.<section>.<type>"
95
+ # for the given file extension. Uses the name and section from
96
+ # the source file path but falls back on the name and section
97
+ # defined in the document.
98
+ def basename(type = nil)
99
+ type = nil if ['', 'roff'].include?(type.to_s)
100
+ [path_name || @name, path_section || @section, type]
101
+ .compact.join('.')
102
+ end
103
+
104
+ # Construct a path for a file near the source file. Uses the
105
+ # Document#basename method to generate the basename part and
106
+ # appends it to the dirname of the source document.
107
+ def path_for(type = nil)
108
+ if @outdir
109
+ File.join(@outdir, basename(type))
110
+ elsif @basename
111
+ File.join(File.dirname(path), basename(type))
112
+ else
113
+ basename(type)
114
+ end
115
+ end
116
+
117
+ # Returns the <name> part of the path, or nil when no path is
118
+ # available. This is used as the manual page name when the
119
+ # file contents do not include a name section.
120
+ def path_name
121
+ return unless @basename
122
+
123
+ parts = @basename.split('.')
124
+ parts.pop if parts.length > 1 && parts.last =~ /^\w+$/
125
+ parts.pop if parts.last =~ /^\d+$/
126
+ parts.join('.')
127
+ end
128
+
129
+ # Returns the <section> part of the path, or nil when
130
+ # no path is available.
131
+ def path_section
132
+ $1 if @basename.to_s =~ /\.(\d\w*)\./
133
+ end
134
+
135
+ # Returns the manual page name based first on the document's
136
+ # contents and then on the path name. Usually a single word name of
137
+ # a program or filename; displayed along with the section in
138
+ # the left and right portions of the header as well as the bottom
139
+ # right section of the footer.
140
+ def name
141
+ @name || path_name
142
+ end
143
+
144
+ # Truthful when the name was extracted from the name section
145
+ # of the document.
146
+ def name?
147
+ !@name.nil?
148
+ end
149
+
150
+ # Returns the manual page section based first on the document's
151
+ # contents and then on the path name. A string whose first character
152
+ # is numeric; displayed in parenthesis along with the name.
153
+ def section
154
+ @section || path_section
155
+ end
156
+
157
+ # True when the section number was extracted from the name
158
+ # section of the document.
159
+ def section?
160
+ !@section.nil?
161
+ end
162
+
163
+ # The name used to reference this manual.
164
+ def reference_name
165
+ name + (section && "(#{section})").to_s
166
+ end
167
+
168
+ # Truthful when the document started with an h1 but did not follow
169
+ # the "<name>(<sect>) -- <tagline>" convention. We assume this is some kind
170
+ # of custom title.
171
+ def title?
172
+ !name? && tagline
173
+ end
174
+
175
+ # The document's title when no name section was defined. When a name section
176
+ # exists, this value is nil.
177
+ def title
178
+ @tagline unless name?
179
+ end
180
+
181
+ # The date the man page was published. If not set explicitly,
182
+ # it first checks for "SOURCE_DATE_EPOCH" to support reproducible
183
+ # builds, then the file's modified time or, if no file is given,
184
+ # the current time. Center displayed in the document footer.
185
+ def date
186
+ return @date if @date
187
+
188
+ return Time.at(ENV['SOURCE_DATE_EPOCH'].to_i).gmtime if ENV['SOURCE_DATE_EPOCH']
189
+
190
+ return File.mtime(path) if File.exist?(path)
191
+
192
+ Time.now
193
+ end
194
+
195
+ # Retrieve a list of top-level section headings in the document and return
196
+ # as an array of +[id, text]+ tuples, where +id+ is the element's generated
197
+ # id and +text+ is the inner text of the heading element.
198
+ def toc
199
+ @toc ||=
200
+ html.search('h2[@id]').map { |h2| [h2.attributes['id'].content.upcase, h2.inner_text] }
201
+ end
202
+ alias section_heads toc
203
+
204
+ # Styles to insert in the generated HTML output. This is a simple Array of
205
+ # string module names or file paths.
206
+ def styles=(styles)
207
+ @styles = (%w[man] + styles).uniq
208
+ end
209
+
210
+ # Sniff the document header and extract basic document metadata. Return a
211
+ # tuple of the form: [name, section, description], where missing information
212
+ # is represented by nil and any element may be missing.
213
+ def sniff
214
+ html = Kramdown::Document.new(data[0, 512], auto_ids: false,
215
+ smart_quotes: ['apos', 'apos', 'quot', 'quot'],
216
+ typographic_symbols: { hellip: '...', ndash: '--', mdash: '--' }).to_html
217
+ heading, html = html.split("</h1>\n", 2)
218
+ return [nil, nil, nil] if html.nil?
219
+
220
+ case heading
221
+ when /([\w_.\[\]~+=@:-]+)\s*\((\d\w*)\)\s*-+\s*(.*)/
222
+ # name(section) -- description
223
+ [$1, $2, $3]
224
+ when /([\w_.\[\]~+=@:-]+)\s+-+\s+(.*)/
225
+ # name -- description
226
+ [$1, nil, $2]
227
+ else
228
+ # description
229
+ [nil, nil, heading.sub('<h1>', '')]
230
+ end
231
+ end
232
+
233
+ # Preprocessed markdown input text.
234
+ def markdown
235
+ @markdown ||= process_markdown!
236
+ end
237
+
238
+ # A Nokogiri DocumentFragment for the manual content fragment.
239
+ def html
240
+ @html ||= process_html!
241
+ end
242
+
243
+ # Convert the document to :roff, :html, or :html_fragment and
244
+ # return the result as a string.
245
+ def convert(format)
246
+ send "to_#{format}"
247
+ end
248
+
249
+ # Convert the document to roff and return the result as a string.
250
+ def to_roff
251
+ RoffFilter.new(
252
+ to_html_fragment(nil),
253
+ name, section, tagline,
254
+ manual, organization, date
255
+ ).to_s
256
+ end
257
+
258
+ # Convert the document to HTML and return the result as a string.
259
+ # The returned string is a complete HTML document.
260
+ def to_html
261
+ layout = ENV['RONN_LAYOUT']
262
+ layout_path = nil
263
+ if layout
264
+ layout_path = File.expand_path(layout)
265
+ unless File.exist?(layout_path)
266
+ warn "warn: can't find #{layout}, using default layout."
267
+ layout_path = nil
268
+ end
269
+ end
270
+
271
+ template = Ronn::Template.new(self)
272
+ template.context.push html: to_html_fragment(nil)
273
+ template.render(layout_path || 'default')
274
+ end
275
+
276
+ # Convert the document to HTML and return the result
277
+ # as a string. The HTML does not include <html>, <head>,
278
+ # or <style> tags.
279
+ def to_html_fragment(wrap_class = 'mp')
280
+ frag_nodes = html.at('body').children
281
+ out = frag_nodes.to_s.rstrip
282
+ out = "<div class='#{wrap_class}'>#{out}\n</div>" unless wrap_class.nil?
283
+ out
284
+ end
285
+
286
+ def to_markdown
287
+ markdown
288
+ end
289
+
290
+ def to_h
291
+ %w[name section tagline manual organization date styles toc]
292
+ .each_with_object({}) { |name, hash| hash[name] = send(name) }
293
+ end
294
+
295
+ def to_yaml
296
+ require 'yaml'
297
+ to_h.to_yaml
298
+ end
299
+
300
+ def to_json(*_args)
301
+ require 'json'
302
+ to_h.merge('date' => date.iso8601).to_json
303
+ end
304
+
305
+ protected
306
+
307
+ ##
308
+ # Document Processing
309
+
310
+ # Parse the document and extract the name, section, and tagline from its
311
+ # contents. This is called while the object is being initialized.
312
+ def preprocess!
313
+ input_html
314
+ nil
315
+ end
316
+
317
+ def input_html
318
+ @input_html ||= strip_heading(Kramdown::Document.new(markdown,
319
+ auto_ids: false,
320
+ input: 'GFM',
321
+ hard_wrap: 'false',
322
+ syntax_highlighter_opts: 'line_numbers: false',
323
+ smart_quotes: ['apos', 'apos', 'quot', 'quot'],
324
+ typographic_symbols: { hellip: '...', ndash: '--', mdash: '--' }).to_html)
325
+ end
326
+
327
+ def strip_heading(html)
328
+ heading, html = html.split("</h1>\n", 2)
329
+ html || heading
330
+ end
331
+
332
+ def process_markdown!
333
+ md = markdown_filter_heading_anchors(data)
334
+ md = markdown_filter_link_index(md)
335
+ markdown_filter_angle_quotes(md)
336
+ end
337
+
338
+ def process_html!
339
+ wrapped_html = "<html>\n <body>\n#{input_html}\n </body>\n</html>"
340
+ @html = Nokogiri::HTML.parse(wrapped_html)
341
+ html_filter_angle_quotes
342
+ html_filter_definition_lists
343
+ html_filter_inject_name_section
344
+ html_filter_heading_anchors
345
+ html_filter_annotate_bare_links
346
+ html_filter_manual_reference_links
347
+ @html
348
+ end
349
+
350
+ ##
351
+ # Filters
352
+
353
+ # Appends all index links to the end of the document as Markdown reference
354
+ # links. This lets us use [foo(3)][] syntax to link to index entries.
355
+ def markdown_filter_link_index(markdown)
356
+ return markdown if index.nil? || index.empty?
357
+
358
+ markdown << "\n\n"
359
+ index.each { |ref| markdown << "[#{ref.name}]: #{ref.url}\n" }
360
+ markdown
361
+ end
362
+
363
+ # Add [id]: #ANCHOR elements to the markdown source text for all sections.
364
+ # This lets us use the [SECTION-REF][] syntax
365
+ def markdown_filter_heading_anchors(markdown)
366
+ first = true
367
+ markdown.split("\n").grep(/^[#]{2,5} +[\w '-]+[# ]*$/).each do |line|
368
+ markdown << "\n\n" if first
369
+ first = false
370
+ title = line.gsub(/[^\w -]/, '').strip
371
+ anchor = title.gsub(/\W+/, '-').gsub(/(^-+|-+$)/, '')
372
+ markdown << "[#{title}]: ##{anchor} \"#{title}\"\n"
373
+ end
374
+ markdown
375
+ end
376
+
377
+ # Convert <WORD> to <var>WORD</var> but only if WORD isn't an HTML tag.
378
+ def markdown_filter_angle_quotes(markdown)
379
+ markdown.gsub(/(?<!\\)<([^:.\/]+?)>/) do |match|
380
+ contents = $1
381
+ tag, attrs = contents.split(' ', 2)
382
+ if attrs =~ /\/=/ || html_element?(tag.sub(/^\//, '')) ||
383
+ data.include?("</#{tag}>") || contents =~ /^!/
384
+ match.to_s
385
+ else
386
+ "<var>#{contents}</var>"
387
+ end
388
+ end
389
+ end
390
+
391
+ # Perform angle quote (<THESE>) post filtering.
392
+ def html_filter_angle_quotes
393
+ # convert all angle quote vars nested in code blocks
394
+ # back to the original text
395
+ code_nodes = @html.search('code')
396
+ code_nodes.search('.//text() | text()').each do |node|
397
+ next unless node.to_html.include?('var&gt;')
398
+
399
+ new =
400
+ node.to_html
401
+ .gsub('&lt;var&gt;', '&lt;')
402
+ .gsub('&lt;/var&gt;', '>')
403
+ node.swap(new)
404
+ end
405
+ end
406
+
407
+ # Convert special format unordered lists to definition lists.
408
+ def html_filter_definition_lists
409
+ # process all unordered lists depth-first
410
+ @html.search('ul').to_a.reverse_each do |ul|
411
+ items = ul.search('li')
412
+ next if items.any? { |item| item.inner_text.strip.split("\n", 2).first !~ /:$/ }
413
+
414
+ dl = Nokogiri::XML::Node.new 'dl', html
415
+ items.each do |item|
416
+ # This processing is specific to how Markdown generates definition lists
417
+ term, definition = item.inner_html.strip.split(":\n", 2)
418
+ term = term.sub(/^<p>/, '')
419
+
420
+ dt = Nokogiri::XML::Node.new 'dt', html
421
+ dt.children = Nokogiri::HTML.fragment(term)
422
+ dt.attributes['class'] = 'flush' if dt.inner_text.length <= 7
423
+
424
+ dd = Nokogiri::XML::Node.new 'dd', html
425
+ dd_contents = Nokogiri::HTML.fragment(definition)
426
+ dd.children = dd_contents
427
+
428
+ dl.add_child(dt)
429
+ dl.add_child(dd)
430
+ end
431
+ ul.replace(dl)
432
+ end
433
+ end
434
+
435
+ def html_filter_inject_name_section
436
+ markup =
437
+ if title?
438
+ "<h1>#{title}</h1>"
439
+ elsif name
440
+ "<h2>NAME</h2>\n" \
441
+ "<p class='man-name'>\n <code>#{name}</code>" +
442
+ (tagline ? " - <span class='man-whatis'>#{tagline}</span>\n" : "\n") +
443
+ "</p>\n"
444
+ end
445
+ return unless markup
446
+
447
+ if html.at('body').first_element_child
448
+ html.at('body').first_element_child.before(Nokogiri::HTML.fragment(markup))
449
+ else
450
+ html.at('body').add_child(Nokogiri::HTML.fragment(markup))
451
+ end
452
+ end
453
+
454
+ # Add URL anchors to all HTML heading elements.
455
+ def html_filter_heading_anchors
456
+ h_nodes = @html.search('//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 and not(@id)]')
457
+ h_nodes.each do |heading|
458
+ heading.set_attribute('id', heading.inner_text.gsub(/\W+/, '-'))
459
+ end
460
+ end
461
+
462
+ # Add a 'data-bare-link' attribute to hyperlinks
463
+ # whose text labels are the same as their href URLs.
464
+ def html_filter_annotate_bare_links
465
+ @html.search('a[@href]').each do |node|
466
+ href = node.attributes['href'].content
467
+ text = node.inner_text
468
+
469
+ next unless href == text || href[0] == '#' ||
470
+ CGI.unescapeHTML(href) == "mailto:#{CGI.unescapeHTML(text)}"
471
+
472
+ node.set_attribute('data-bare-link', 'true')
473
+ end
474
+ end
475
+
476
+ # Convert text of the form "name(section)" or "<code>name</code>(section)
477
+ # to a hyperlink. The URL is obtained from the index.
478
+ def html_filter_manual_reference_links
479
+ return if index.nil?
480
+
481
+ name_pattern = '[0-9A-Za-z_:.+=@~-]+'
482
+
483
+ # Convert "name(section)" by traversing text nodes searching for
484
+ # text that fits the pattern. This is the original implementation.
485
+ @html.search('.//text() | text()').each do |node|
486
+ next unless node.content.include?(')')
487
+ next if %w[pre code h1 h2 h3].include?(node.parent.name)
488
+ next if child_of?(node, 'a')
489
+ node.swap(node.content.gsub(/(#{name_pattern})(\(\d+\w*\))/) do
490
+ html_build_manual_reference_link($1, $2)
491
+ end)
492
+ end
493
+
494
+ # Convert "<code>name</code>(section)" by traversing <code> nodes.
495
+ # For each one that contains exactly an acceptable manual page name,
496
+ # the next sibling is checked and must be a text node beginning
497
+ # with a valid section in parentheses.
498
+ @html.search('code').each do |node|
499
+ next if %w[pre code h1 h2 h3].include?(node.parent.name)
500
+ next if child_of?(node, 'a')
501
+ next unless node.inner_text =~ /^#{name_pattern}$/
502
+ sibling = node.next
503
+ next unless sibling
504
+ next unless sibling.text?
505
+ next unless sibling.content =~ /^\((\d+\w*)\)/
506
+ node.swap(html_build_manual_reference_link(node, "(#{$1})"))
507
+ sibling.content = sibling.content.gsub(/^\(\d+\w*\)/, '')
508
+ end
509
+ end
510
+
511
+ # HTMLize the manual page reference. The result is an <a> if the
512
+ # page appears in the index, otherwise it is a <span>. The first
513
+ # argument may be an HTML element or a string. The second should
514
+ # be a string of the form "(#{section})".
515
+ def html_build_manual_reference_link(name_or_node, section)
516
+ name = if name_or_node.respond_to?(:inner_text)
517
+ name_or_node.inner_text
518
+ else
519
+ name_or_node
520
+ end
521
+ ref = index["#{name}#{section}"]
522
+ if ref
523
+ "<a class='man-ref' href='#{ref.url}'>#{name_or_node}<span class='s'>#{section}</span></a>"
524
+ else
525
+ # warn "warn: manual reference not defined: '#{name}#{section}'"
526
+ "<span class='man-ref'>#{name_or_node}<span class='s'>#{section}</span></span>"
527
+ end
528
+ end
529
+ end
530
+ end
data/lib/ronn/index.rb ADDED
@@ -0,0 +1,180 @@
1
+ require 'ronn'
2
+
3
+ module Ronn
4
+ # Maintains a list of links / references to manuals and other resources.
5
+ class Index
6
+ include Enumerable
7
+
8
+ attr_reader :path, :references
9
+
10
+ # Retrieve an Index for <path>, where <path> is a directory or normal
11
+ # file. The index is loaded from the corresponding index.txt file if
12
+ # one exists.
13
+ def self.[](path)
14
+ (@indexes ||= {})[index_path_for_file(path)] ||=
15
+ Index.new(index_path_for_file(path))
16
+ end
17
+
18
+ def self.index_path_for_file(file)
19
+ File.expand_path(
20
+ if File.directory?(file)
21
+ File.join(file, 'index.txt')
22
+ else
23
+ File.join(File.dirname(file), 'index.txt')
24
+ end
25
+ )
26
+ end
27
+
28
+ def initialize(path)
29
+ @path = path
30
+ @references = []
31
+ @manuals = {}
32
+
33
+ if block_given?
34
+ read! yield
35
+ elsif exist?
36
+ read! File.read(path)
37
+ end
38
+ end
39
+
40
+ # Determine whether the index file exists.
41
+ def exist?
42
+ File.exist?(path)
43
+ end
44
+
45
+ # Load index data from a string.
46
+ def read!(data)
47
+ data.each_line do |line|
48
+ line = line.strip.gsub(/\s*#.*$/, '')
49
+ unless line.empty?
50
+ name, url = line.split(/\s+/, 2)
51
+ @references << reference(name, url)
52
+ end
53
+ end
54
+ end
55
+
56
+ ##
57
+ # Enumerable and friends
58
+
59
+ def each(&block)
60
+ references.each(&block)
61
+ end
62
+
63
+ def size
64
+ references.size
65
+ end
66
+
67
+ def first
68
+ references.first
69
+ end
70
+
71
+ def last
72
+ references.last
73
+ end
74
+
75
+ def empty?
76
+ references.empty?
77
+ end
78
+
79
+ def [](name)
80
+ references.find { |ref| ref.name == name }
81
+ end
82
+
83
+ def reference(name, path)
84
+ Reference.new(self, name, path)
85
+ end
86
+
87
+ def <<(path)
88
+ raise ArgumentError, 'local paths only' if path =~ /(https?|mailto):/
89
+ return self if any? { |ref| ref.path == File.expand_path(path) }
90
+ relative_path = relative_to_index(path)
91
+ @references << \
92
+ if path =~ /\.ronn?$/
93
+ reference manual(path).reference_name, relative_path
94
+ else
95
+ reference File.basename(path), relative_path
96
+ end
97
+ self
98
+ end
99
+
100
+ def add_manual(manual)
101
+ @manuals[File.expand_path(manual.path)] = manual
102
+ self << manual.path
103
+ end
104
+
105
+ def manual(path)
106
+ @manuals[File.expand_path(path)] ||= Document.new(path)
107
+ end
108
+
109
+ def manuals
110
+ select { |ref| ref.relative? && ref.ronn? }
111
+ .map { |ref| manual(ref.path) }
112
+ end
113
+
114
+ ##
115
+ # Converting
116
+
117
+ def to_text
118
+ map { |ref| [ref.name, ref.location].join(' ') }.join("\n")
119
+ end
120
+
121
+ def to_a
122
+ references
123
+ end
124
+
125
+ def to_h
126
+ to_a.map(&:to_hash)
127
+ end
128
+
129
+ def relative_to_index(path)
130
+ path = File.expand_path(path)
131
+ index_dir = File.dirname(File.expand_path(self.path))
132
+ path.sub(/^#{index_dir}\//, '')
133
+ end
134
+ end
135
+
136
+ # An individual index reference. A reference can point to one of a few types
137
+ # of locations:
138
+ #
139
+ # - URLs: "http://man.cx/crontab(5)"
140
+ # - Relative paths to ronn manuals: "crontab.5.ronn"
141
+ #
142
+ # The #url method should be used to obtain the href value for HTML.
143
+ class Reference
144
+ attr_reader :name, :location
145
+
146
+ def initialize(index, name, location)
147
+ @index = index
148
+ @name = name
149
+ @location = location
150
+ end
151
+
152
+ def manual?
153
+ name =~ /\([0-9]\w*\)$/
154
+ end
155
+
156
+ def ronn?
157
+ location =~ /\.ronn?$/
158
+ end
159
+
160
+ def remote?
161
+ location =~ /^(?:https?|mailto):/
162
+ end
163
+
164
+ def relative?
165
+ !remote?
166
+ end
167
+
168
+ def url
169
+ if remote?
170
+ location
171
+ else
172
+ location.chomp('.ronn') + '.html'
173
+ end
174
+ end
175
+
176
+ def path
177
+ File.expand_path(location, File.dirname(@index.path)) if relative?
178
+ end
179
+ end
180
+ end