nronn 0.10.1.pre2

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.
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