asciisourcerer 0.4.0 → 0.5.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.
@@ -22,7 +22,8 @@ module Sourcerer
22
22
  @config = {
23
23
  preserve_heading_ids: true,
24
24
  strip_internal_links: false,
25
- convert_tables_to_markdown: false
25
+ convert_tables_to_markdown: false,
26
+ convert_dls_to_markdown: true
26
27
  }
27
28
 
28
29
  class << self
@@ -34,6 +35,7 @@ module Sourcerer
34
35
  # preserve_heading_ids: (default: true) Include <a id="..."> anchors before headings
35
36
  # strip_internal_links: (default: false) Remove href from internal anchor links, keeping only text
36
37
  # convert_tables_to_markdown: (default: false) Convert all tables to markdown UNLESS they have .no-markdown class
38
+ # convert_dls_to_markdown: (default: true) Convert all DLs to markdown UNLESS they have .no-markdown class
37
39
  def self.bootstrap! options={}
38
40
  @config.merge!(options)
39
41
 
@@ -47,6 +49,7 @@ module Sourcerer
47
49
  register_blockquote_converter
48
50
  register_comment_converter
49
51
  register_link_converter
52
+ register_list_converters
50
53
  end
51
54
 
52
55
  # Enhanced Pre converter to handle additional code block language patterns
@@ -138,33 +141,67 @@ module Sourcerer
138
141
  end
139
142
  end
140
143
 
141
- # Definition list converter: preserve semantic list tags in output.
142
- class DlConverter < ReverseMarkdown::Converters::Base
144
+ # Definition list passthrough/converter: converts DL blocks to Markdown by default.
145
+ # DL blocks with .to-markdown class (or a parent div with that class) are converted to Markdown.
146
+ # Supports global conversion via convert_dls_to_markdown config or dls-to-markdown frontmatter key.
147
+ # Per-node .to-markdown and .no-markdown classes override the global mode.
148
+ # In AsciiDoc html5 output, [.to-markdown] on a dlist places the class on the outer <div>.
149
+ class DlPassthrough < ReverseMarkdown::Converters::Base
143
150
  def convert node, state={}
144
- body = node.children.map { |child| treat(child, state) }.join.strip
145
- attrs = []
146
- attrs << %( class="#{node['class']}") if node['class']
147
- attrs << %( role="#{node['role']}") if node['role']
148
- "<dl#{attrs.join}>\n#{body}\n</dl>\n"
151
+ global_mode = Thread.current[:sourcerer_dl_conversion_mode] || false
152
+
153
+ has_to_markdown = check_class_on_node(node, 'to-markdown')
154
+ has_no_markdown = check_class_on_node(node, 'no-markdown')
155
+
156
+ # Also check parent <div> wrapper (html5 backend wraps dl in <div class="dlist ...">)
157
+ parent = node.parent
158
+ if parent && parent.name == 'div'
159
+ has_to_markdown ||= check_class_on_node(parent, 'to-markdown')
160
+ has_no_markdown ||= check_class_on_node(parent, 'no-markdown')
161
+ end
162
+
163
+ should_convert = if has_no_markdown
164
+ false
165
+ elsif has_to_markdown
166
+ true
167
+ else
168
+ global_mode
169
+ end
170
+
171
+ if should_convert
172
+ body = node.children.map { |child| treat(child, state) }.join.strip
173
+ "#{body}\n"
174
+ else
175
+ "#{node.to_html}\n"
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def check_class_on_node node, class_name
182
+ node['class'].to_s.split.include?(class_name)
149
183
  end
150
184
  end
151
185
 
152
- # Definition term converter: preserves <dt> with classes, converts content.
186
+ # Definition term converter: formats term as italicized with colon.
153
187
  class DtConverter < ReverseMarkdown::Converters::Base
154
188
  def convert node, state={}
155
- class_attr = node['class'] ? %( class="#{node['class']}") : ''
156
- "<dt#{class_attr}>#{treat_children(node, state)}</dt>\n"
189
+ term_text = treat_children(node, state).strip
190
+ "**#{term_text}:**\n"
157
191
  end
158
192
  end
159
193
 
160
- # Definition description converter: preserves <dd>, converts nested content.
194
+ # Definition description converter: indents definition content by 3 spaces for Markdown.
195
+ # Block-level elements (lists, code blocks, etc.) within a definition are indented as-is,
196
+ # preserving their internal structure while still visually nesting under the term.
161
197
  class DdConverter < ReverseMarkdown::Converters::Base
162
198
  def convert node, state={}
163
- content = treat_children(node, state)
164
- attrs = []
165
- attrs << %( class="#{node['class']}") if node['class']
166
- attrs << %( role="#{node['role']}") if node['role']
167
- "<dd#{attrs.join}>\n#{content.strip}\n</dd>\n"
199
+ content = treat_children(node, state).strip
200
+ # Indent non-blank lines of the definition by 3 spaces (works for both inline and
201
+ # block content); blank lines are left empty so they stay collapsible as paragraph
202
+ # breaks instead of becoming whitespace-only lines that pile up under nesting.
203
+ indented = content.split("\n").map { |line| line.strip.empty? ? '' : " #{line}" }.join("\n")
204
+ "#{indented}\n\n"
168
205
  end
169
206
  end
170
207
 
@@ -426,6 +463,12 @@ module Sourcerer
426
463
  # Tables with "to-markdown" class are converted via ReverseMarkdown instead.
427
464
  # Supports both html5 (class on <table>) and html5s (class on parent <div class="table-block">).
428
465
  # Per-table classes (.to-markdown, .no-markdown) override the global conversion mode.
466
+ #
467
+ # Horizontal dlists ([horizontal]) render as a classless <table> inside a
468
+ # <div class="hdlist">, not as a <dl>, so they're intercepted here rather than
469
+ # by DlPassthrough. They follow the DL conversion mode/overrides (not the table
470
+ # mode), converting each hdlist1/hdlist2 row to "**Term:** Description" instead
471
+ # of a Markdown table.
429
472
  class TablePassthrough < ReverseMarkdown::Converters::Base
430
473
  def initialize
431
474
  super
@@ -433,6 +476,8 @@ module Sourcerer
433
476
  end
434
477
 
435
478
  def convert node, state={}
479
+ return convert_hdlist(node, state) if hdlist_table?(node)
480
+
436
481
  global_mode = Thread.current[:sourcerer_table_conversion_mode] || false
437
482
 
438
483
  # Check for per-table classes (on table or parent wrapper)
@@ -464,6 +509,46 @@ module Sourcerer
464
509
 
465
510
  private
466
511
 
512
+ def hdlist_table? node
513
+ parent = node.parent
514
+ parent && parent.name == 'div' && check_class_on_node(parent, 'hdlist')
515
+ end
516
+
517
+ def convert_hdlist node, state={}
518
+ global_mode = Thread.current[:sourcerer_dl_conversion_mode] || false
519
+
520
+ has_to_markdown = check_class_on_node(node, 'to-markdown')
521
+ has_no_markdown = check_class_on_node(node, 'no-markdown')
522
+
523
+ parent = node.parent
524
+ if parent && parent.name == 'div'
525
+ has_to_markdown ||= check_class_on_node(parent, 'to-markdown')
526
+ has_no_markdown ||= check_class_on_node(parent, 'no-markdown')
527
+ end
528
+
529
+ should_convert = if has_no_markdown
530
+ false
531
+ elsif has_to_markdown
532
+ true
533
+ else
534
+ global_mode
535
+ end
536
+
537
+ return "#{node.to_html}\n" unless should_convert
538
+
539
+ body = node.css('> tr').map { |row| convert_hdlist_row(row, state) }.join
540
+ "#{body}\n"
541
+ end
542
+
543
+ def convert_hdlist_row row, state={}
544
+ # Match by column position, not the hdlist1/hdlist2 classes: normalize_html_for_markdown
545
+ # (clean_html5s_tables!) strips class attributes from td/tr before this converter runs.
546
+ term_node, desc_node = row.css('> td')
547
+ term = term_node ? treat_children(term_node, state).strip : ''
548
+ desc = desc_node ? treat_children(desc_node, state).strip : ''
549
+ "**#{term}:** #{desc}\n"
550
+ end
551
+
467
552
  def check_class_on_node node, class_name
468
553
  node['class'].to_s.split.include?(class_name)
469
554
  end
@@ -509,7 +594,7 @@ module Sourcerer
509
594
  is_checked = checkbox['checked'] || checkbox['data-item-complete'] == '1'
510
595
  # Remove the checkbox from the DOM so it doesn't get rendered again
511
596
  checkbox.remove
512
- is_checked ? '<!--CHECKBOX_CHECKED--> ' : '<!--CHECKBOX_UNCHECKED--> '
597
+ is_checked ? '- <!--CHECKBOX_CHECKED--> ' : '- <!--CHECKBOX_UNCHECKED--> '
513
598
  else
514
599
  prefix_for(node)
515
600
  end
@@ -526,9 +611,11 @@ module Sourcerer
526
611
  result = "#{indentation}#{prefix}#{content}\n"
527
612
 
528
613
  nested_lists.each do |nested_list|
529
- nested_state = state.merge(ol_count: state.fetch(:ol_count, 0) + 1)
530
- nested_md = treat(nested_list, nested_state).strip
531
- result << "#{nested_md}\n" unless nested_md.empty?
614
+ # Pass the same state; the Ul/Ol converter will handle ol_count incrementing.
615
+ # Use sub to strip only the leading newline emitted by the Ul/Ol converter,
616
+ # preserving per-line indentation built by indentation_from.
617
+ nested_md = treat(nested_list, state).sub(/\A\n+/, '').rstrip
618
+ result << "#{nested_md}\n" unless nested_md.strip.empty?
532
619
  end
533
620
 
534
621
  result
@@ -546,8 +633,11 @@ module Sourcerer
546
633
  end
547
634
 
548
635
  def indentation_from state
636
+ # Mirror ReverseMarkdown's built-in Li behaviour: ol_count is incremented by the
637
+ # Ul/Ol converter before reaching LI, so subtract 1 to get the nesting depth.
638
+ # ol_count 1 = top level (no indent), 2 = nested once (2 spaces), etc.
549
639
  length = state.fetch(:ol_count, 0)
550
- ' ' * [length - 1, 0].max
640
+ ' ' * [length - 1, 0].max
551
641
  end
552
642
  end # class LiWithNestedLists
553
643
 
@@ -570,8 +660,10 @@ module Sourcerer
570
660
  end
571
661
 
572
662
  # Register all definition list converters.
663
+ # DlPassthrough handles opt-in conversion; DtConverter and DdConverter are
664
+ # called only when DlPassthrough decides to convert a given <dl>.
573
665
  def self.register_dl_converters
574
- ReverseMarkdown::Converters.register :dl, DlConverter.new
666
+ ReverseMarkdown::Converters.register :dl, DlPassthrough.new
575
667
  ReverseMarkdown::Converters.register :dt, DtConverter.new
576
668
  ReverseMarkdown::Converters.register :dd, DdConverter.new
577
669
  end
@@ -618,6 +710,11 @@ module Sourcerer
618
710
  ReverseMarkdown::Converters.register :a, LinkConverter.new
619
711
  end
620
712
 
713
+ # Register list item converter to handle nested lists and checkboxes.
714
+ def self.register_list_converters
715
+ ReverseMarkdown::Converters.register :li, LiWithNestedLists.new
716
+ end
717
+
621
718
  # Normalize block titles so escaped inline emphasis from html5s is converted
622
719
  # to markdown emphasis consistently with html5 conversions.
623
720
  def self.normalize_block_title text
@@ -650,13 +747,16 @@ module Sourcerer
650
747
  # Convert HTML into Markdown with MarkDownGrade converters.
651
748
  # Options include:
652
749
  # convert_tables_to_markdown: Override global config for table conversion (true/false)
750
+ # convert_dls_to_markdown: Override global config for DL conversion (true/false)
653
751
  def self.convert_html html, options={}
654
752
  bootstrap! unless @setup_complete
655
753
  @setup_complete = true
656
754
 
657
- # Determine effective table conversion mode
658
- effective_mode = determine_table_conversion_mode(html.to_s, options)
659
- Thread.current[:sourcerer_table_conversion_mode] = effective_mode
755
+ # Determine effective table and DL conversion modes
756
+ effective_table_mode = determine_table_conversion_mode(html.to_s, options)
757
+ effective_dl_mode = determine_dl_conversion_mode(html.to_s, options)
758
+ Thread.current[:sourcerer_table_conversion_mode] = effective_table_mode
759
+ Thread.current[:sourcerer_dl_conversion_mode] = effective_dl_mode
660
760
 
661
761
  begin
662
762
  normalized_html = normalize_html_for_markdown(html.to_s)
@@ -664,14 +764,33 @@ module Sourcerer
664
764
  markdown = markdown.gsub(/(\*\*[^\n]+\*\* \n)\n+(?=\S)/, '\\1')
665
765
  markdown = markdown.gsub(/<figcaption>\s+/, '<figcaption>')
666
766
  markdown = markdown.gsub(%r{\s+</figcaption>}, '</figcaption>')
767
+ markdown = normalize_blank_lines(markdown)
667
768
 
668
- markdown.gsub('<!--CHECKBOX_CHECKED-->', '- [x]')
669
- .gsub('<!--CHECKBOX_UNCHECKED-->', '- [ ]')
769
+ # Replace checkbox markers: handle indented list items and maintain correct dash placement
770
+ replace_checkbox_markers(markdown)
670
771
  ensure
671
772
  Thread.current[:sourcerer_table_conversion_mode] = nil
773
+ Thread.current[:sourcerer_dl_conversion_mode] = nil
672
774
  end
673
775
  end
674
776
 
777
+ # Collapse whitespace-only lines to truly blank ones, then collapse runs of
778
+ # 3+ consecutive newlines down to a single blank line (one \n\n). Nested
779
+ # definition lists and example blocks otherwise stack indentation and blank
780
+ # lines from each conversion level, producing long runs of whitespace-only lines.
781
+ def self.normalize_blank_lines markdown
782
+ markdown.gsub(/^[ \t]+$/, '').gsub(/\n{3,}/, "\n\n")
783
+ end
784
+
785
+ # Replace checkbox placeholder markers with proper Markdown checkbox syntax.
786
+ # Handles various indentation levels and ensures correct list item formatting.
787
+ def self.replace_checkbox_markers markdown
788
+ # Replace checkbox markers that appear after list item dashes
789
+ # Pattern: optional indentation + dash + space + marker + space
790
+ markdown.gsub(/^(\s*- )<!--CHECKBOX_CHECKED-->\s/, '\1[x] ')
791
+ .gsub(/^(\s*- )<!--CHECKBOX_UNCHECKED-->\s/, '\1[ ] ')
792
+ end
793
+
675
794
  def self.convert html, options={}
676
795
  convert_html(html, options)
677
796
  end
@@ -773,6 +892,38 @@ module Sourcerer
773
892
  @config[:convert_tables_to_markdown]
774
893
  end
775
894
 
895
+ # Determine the effective DL conversion mode from options, frontmatter, or global config.
896
+ #
897
+ # Precedence:
898
+ # 1. Explicit convert_dls_to_markdown option in parameters
899
+ # 2. Document-level setting from frontmatter key: dls-to-markdown
900
+ # 3. Global config setting
901
+ #
902
+ # @param html_body [String] The HTML document body.
903
+ # @param options [Hash] Optional override.
904
+ # @return [Boolean] Whether DLs should be converted to markdown by default.
905
+ def self.determine_dl_conversion_mode html_body, options
906
+ return options[:convert_dls_to_markdown] if options.key?(:convert_dls_to_markdown)
907
+
908
+ document_mode = extract_dl_conversion_mode_from_html(html_body)
909
+ return document_mode unless document_mode.nil?
910
+
911
+ @config[:convert_dls_to_markdown]
912
+ end
913
+
914
+ # Extract DL conversion mode from document frontmatter.
915
+ #
916
+ # Checks for YAML frontmatter key: dls-to-markdown
917
+ #
918
+ # @param html_body [String] The HTML document body.
919
+ # @return [Boolean, nil] The setting if found, nil otherwise.
920
+ def self.extract_dl_conversion_mode_from_html html_body
921
+ frontmatter = Sourcerer::YamlFrontmatter.extract(html_body)
922
+ return string_to_boolean(frontmatter['dls-to-markdown']) if frontmatter.key?('dls-to-markdown')
923
+
924
+ nil
925
+ end
926
+
776
927
  # Extract table conversion mode from document frontmatter or page attributes.
777
928
  #
778
929
  # Checks for:
@@ -820,6 +971,8 @@ module Sourcerer
820
971
  :clean_html5s_tables!,
821
972
  :determine_table_conversion_mode,
822
973
  :extract_table_conversion_mode_from_html,
974
+ :determine_dl_conversion_mode,
975
+ :extract_dl_conversion_mode_from_html,
823
976
  :string_to_boolean
824
977
  end # module MarkDownGrade
825
978
  end # module Sourcerer
@@ -36,7 +36,8 @@ module Sourcerer
36
36
  render_entry[:out],
37
37
  data_object: data_obj,
38
38
  attrs_source: attrs_source,
39
- engine: engine)
39
+ engine: engine,
40
+ vars: render_entry[:vars] || {})
40
41
  end
41
42
  end
42
43
 
@@ -49,8 +50,11 @@ module Sourcerer
49
50
  # @param includes_load_paths [Array<String>] Paths for Liquid includes.
50
51
  # @param attrs_source [String] The path to an AsciiDoc file for attributes.
51
52
  # @param engine [String] The template engine to use.
53
+ # @param vars [Hash] Arbitrary caller-supplied variables, exposed to the
54
+ # template as `vars` (e.g. to parameterize a shared template between
55
+ # multiple render entries in a manifest).
52
56
  def self.render_template template_file, data_file, out_file, **options
53
- supported_option_keys = %i[data_object includes_load_paths attrs_source engine]
57
+ supported_option_keys = %i[data_object includes_load_paths attrs_source engine vars]
54
58
  unknown_option_keys = options.keys - supported_option_keys
55
59
  raise ArgumentError, "unknown option(s): #{unknown_option_keys.join(', ')}" unless unknown_option_keys.empty?
56
60
 
@@ -58,6 +62,7 @@ module Sourcerer
58
62
  includes_load_paths = options.fetch(:includes_load_paths, [])
59
63
  attrs_source = options[:attrs_source]
60
64
  engine = options.fetch(:engine, 'liquid')
65
+ vars = (options[:vars] || {}).transform_keys(&:to_s)
61
66
 
62
67
  data = load_render_data(data_file, attrs_source)
63
68
  out_file = File.expand_path(out_file)
@@ -68,7 +73,8 @@ module Sourcerer
68
73
 
69
74
  context = {
70
75
  data_object => data,
71
- 'include' => { data_object => data }
76
+ 'include' => { data_object => data },
77
+ 'vars' => vars
72
78
  }
73
79
 
74
80
  rendered = case engine.to_s
@@ -16,6 +16,7 @@ module Sourcerer
16
16
  admonitions
17
17
  quotes
18
18
  images
19
+ includes
19
20
  ].freeze
20
21
 
21
22
  # Categories included when a caller passes +categories: nil+ (the default).
@@ -30,11 +31,12 @@ module Sourcerer
30
31
  # arguments on {Sourcerer::SourceSkim.skim_file} and friends.
31
32
  # @api private
32
33
  class Config
33
- attr_reader :forms, :categories
34
+ attr_reader :forms, :categories, :descriptions
34
35
 
35
- def initialize forms: [:tree], categories: nil
36
+ def initialize forms: [:tree], categories: nil, descriptions: false
36
37
  @forms = Array(forms).map(&:to_sym)
37
38
  @categories = categories ? Array(categories).map(&:to_sym) : DEFAULT_CATEGORIES.dup
39
+ @descriptions = descriptions
38
40
  end
39
41
 
40
42
  def include? category
@@ -48,6 +50,10 @@ module Sourcerer
48
50
  def flat?
49
51
  @forms.include?(:flat)
50
52
  end
53
+
54
+ def descriptions?
55
+ @descriptions
56
+ end
51
57
  end
52
58
  end
53
59
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yard'
4
+
5
+ module Sourcerer
6
+ module SourceSkim
7
+ # Parses Ruby source code and produces a JSON-ready skim hash.
8
+ #
9
+ # A new instance should be created per-document call. External callers should
10
+ # use {Sourcerer::SourceSkim.skim_file} or {Sourcerer::SourceSkim.skim_string}
11
+ # with a Ruby file or +format: :ruby+ rather than instantiating this class
12
+ # directly.
13
+ # @api private
14
+ class RubySkimmer
15
+ # @param content [String] raw Ruby source code
16
+ # @param config [Config]
17
+ # @return [Hash] JSON-ready skim
18
+ def process content, config: Config.new(forms: [:flat], descriptions: true)
19
+ require 'tempfile'
20
+
21
+ @config = config
22
+
23
+ # Set logging level to ERROR to suppress YARD warnings about missing files or other issues. This ensures that the skimming process is not interrupted by non-critical warnings.
24
+ YARD::Logger.instance.level = 3
25
+
26
+ # YARD's registry is a process-wide global. Without clearing it first,
27
+ # a class/module/method path already registered from a previous
28
+ # #process call keeps pointing at that call's (now-deleted) tempfile,
29
+ # so it would be silently excluded from every subsequent skim.
30
+ YARD::Registry.clear
31
+
32
+ # YARD's parser is designed for documentation generation, so it expects
33
+ # a file path to determine the source type.
34
+ Tempfile.create(['sourcerer', '.rb']) do |tempfile|
35
+ tempfile.write(content)
36
+ tempfile.flush
37
+
38
+ # Parse ONLY the modules, methods, and classes defined in the current file.
39
+ YARD::Parser::SourceParser.parse(tempfile.path)
40
+ objects = YARD::Registry.all(:class, :module, :method).select { |obj| obj.file == tempfile.path }
41
+
42
+ result = {}
43
+ result[:classes] = build_classes(objects.select { |obj| obj.type == :class })
44
+ result[:modules] = build_modules(objects.select { |obj| obj.type == :module })
45
+ result[:methods] = build_methods(objects.select { |obj| obj.type == :method })
46
+ result
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def build_classes classes
53
+ classes.map do |cls|
54
+ {
55
+ name: cls.path,
56
+ line: cls.line
57
+ }.merge(
58
+ @config.descriptions? ? { desc: cls.docstring.to_s } : {})
59
+ end
60
+ end
61
+
62
+ def build_modules modules
63
+ modules.map do |mod|
64
+ {
65
+ name: mod.path,
66
+ line: mod.line
67
+ }.merge(
68
+ @config.descriptions? ? { desc: mod.docstring.to_s } : {})
69
+ end
70
+ end
71
+
72
+ def build_methods methods
73
+ methods.map do |meth|
74
+ {
75
+ name: meth.path,
76
+ line: meth.line
77
+ }.merge(
78
+ @config.descriptions? ? { desc: meth.docstring.to_s } : {})
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -16,6 +16,23 @@ module Sourcerer
16
16
  # raw text (i.e., were not resolved by the parser).
17
17
  INCLUDE_DIRECTIVE_PATTERN = /include::[^\[]+\[[^\]]*\]/
18
18
 
19
+ # Matches a real (non-code-block) include directive at the start of a line,
20
+ # capturing the target path and its attribute list.
21
+ INCLUDE_LINE_PATTERN = /\A\s*include::([^\[]+)\[([^\]]*)\]\s*\z/
22
+
23
+ # Toggled by classic listing (----) / literal (....) block delimiters, so
24
+ # #detect_general_includes can skip include directives already captured
25
+ # (as literal example text) by the code_blocks/literal_blocks categories.
26
+ FENCE_DELIMITER_PATTERN = /\A(?:-{4,}|\.{4,})\s*\z/
27
+
28
+ # A document with a real title line can still end up with no `title`
29
+ # document attribute if a preceding, unresolved include directive
30
+ # disrupts Asciidoctor's header parsing (a known Asciidoctor quirk, not
31
+ # something this document did wrong). When that happens, Document#doctitle
32
+ # silently falls back to the first section's title. Detect that specific
33
+ # failure mode and recover the real title from the raw source instead.
34
+ RAW_TITLE_LINE_PATTERN = /\A=\s+(\S.*)\z/
35
+
19
36
  def process document, config: Config.new
20
37
  @config = config
21
38
  @main_file = document.attr('docfile')
@@ -36,7 +53,7 @@ module Sourcerer
36
53
  assign_line_ends(tree, doc_end)
37
54
 
38
55
  result = {
39
- title: document.doctitle,
56
+ title: doctitle_for(document),
40
57
  lines: doc_end
41
58
  }
42
59
 
@@ -61,12 +78,88 @@ module Sourcerer
61
78
  result[:admonitions] = @admonitions if @config.include?(:admonitions)
62
79
  result[:quotes] = @quotes if @config.include?(:quotes)
63
80
  result[:images] = @images if @config.include?(:images)
81
+ result[:includes] = detect_general_includes(document) if @config.include?(:includes)
64
82
 
65
83
  result
66
84
  end
67
85
 
68
86
  private
69
87
 
88
+ # Prefer Asciidoctor's own doctitle, but recover from the header-parsing
89
+ # failure mode described at RAW_TITLE_LINE_PATTERN: if the `title`
90
+ # attribute never got set (the real signal that header parsing broke,
91
+ # as opposed to a document that's genuinely untitled), look for an
92
+ # explicit `= Title` line in the raw source before trusting the
93
+ # first-section fallback.
94
+ def doctitle_for document
95
+ return document.doctitle if document.attr('title')
96
+
97
+ raw_title = raw_doctitle(document)
98
+ raw_title || document.doctitle
99
+ end
100
+
101
+ def raw_doctitle document
102
+ lines = document.source_lines
103
+ return nil unless lines
104
+
105
+ lines.each do |line|
106
+ return ::Regexp.last_match(1) if line =~ RAW_TITLE_LINE_PATTERN
107
+ # A section heading appearing before any `=` title line means the
108
+ # document genuinely has no title; stop looking.
109
+ break if line =~ /\A==+\s+\S/
110
+ end
111
+ nil
112
+ end
113
+
114
+ # Scan the raw source (outside of listing/literal delimited blocks, which
115
+ # are already covered by the code_blocks/literal_blocks `includes` field)
116
+ # for include directives, whether or not they were resolved by the parser.
117
+ def detect_general_includes document
118
+ lines = document.source_lines
119
+ return [] unless lines
120
+
121
+ offset = source_line_offset(document)
122
+ includes = []
123
+ in_fence = false
124
+ lines.each_with_index do |line, idx|
125
+ if line =~ FENCE_DELIMITER_PATTERN
126
+ in_fence = !in_fence
127
+ next
128
+ end
129
+ next if in_fence
130
+
131
+ next unless (m = line.match(INCLUDE_LINE_PATTERN))
132
+
133
+ entry = { target: m[1].strip, starts_at: idx + 1 + offset }
134
+ entry.merge!(parse_include_attrs(m[2]))
135
+ includes << entry
136
+ end
137
+ includes
138
+ end
139
+
140
+ # document.source_lines reflects content *after* skip-front-matter
141
+ # stripping, while every other category's starts_at comes from
142
+ # Asciidoctor's own line-number tracking, which counts the stripped
143
+ # front matter lines too. Compute that offset so line numbers stay
144
+ # consistent across the whole skim.
145
+ def source_line_offset document
146
+ front_matter = document.attr('front-matter')
147
+ return 0 unless front_matter && !front_matter.empty?
148
+
149
+ front_matter.count("\n") + 1 + 2 # captured lines + both '---' delimiters
150
+ end
151
+
152
+ def parse_include_attrs attrs_str
153
+ attrs = {}
154
+ if (m = attrs_str.match(/\btags?=("[^"]*"|'[^']*'|\S+)/))
155
+ attrs[:tags] = m[1].delete('"\'')
156
+ end
157
+ if (m = attrs_str.match(/\bleveloffset=("[^"]*"|'[^']*'|\S+)/))
158
+ attrs[:leveloffset] = m[1].delete('"\'')
159
+ end
160
+ attrs
161
+ end
162
+
70
163
  def line_count_for file_path
71
164
  return nil unless file_path && File.exist?(file_path)
72
165
 
@@ -6,6 +6,7 @@ require_relative 'yaml_frontmatter'
6
6
  require_relative 'source_skim/config'
7
7
  require_relative 'source_skim/skimmer'
8
8
  require_relative 'source_skim/markdown_skimmer'
9
+ require_relative 'source_skim/ruby_skimmer'
9
10
 
10
11
  module Sourcerer
11
12
  # SourceSkim produces machine-oriented skims of markup source documents.
@@ -57,11 +58,15 @@ module Sourcerer
57
58
  # @param attributes [Hash{String => String}] AsciiDoc only. Asciidoctor
58
59
  # attribute overrides. Silently ignored for Markdown.
59
60
  # @return [Hash] JSON-ready skim
60
- def self.skim_file file_path, forms: nil, format: nil, categories: nil, attributes: {}
61
+ def self.skim_file file_path, forms: nil, format: nil, categories: nil, attributes: {}, descriptions: false
61
62
  fmt = format || detect_format(file_path)
62
63
  if fmt == :markdown
63
64
  config = Config.new(forms: forms || [:flat])
64
65
  MarkdownSkimmer.new.process(File.read(file_path), config: config)
66
+ elsif fmt == :ruby
67
+ RubySkimmer.new.process(
68
+ File.read(file_path),
69
+ config: Config.new(forms: forms || [:flat], descriptions: descriptions))
65
70
  else
66
71
  attrs = LOAD_OPTS[:attributes].merge(attributes)
67
72
  opts = LOAD_OPTS.merge(attributes: attrs)
@@ -82,10 +87,12 @@ module Sourcerer
82
87
  # @param categories [Array<Symbol>, nil] AsciiDoc only
83
88
  # @param attributes [Hash{String => String}] AsciiDoc only
84
89
  # @return [Hash] JSON-ready skim
85
- def self.skim_string content, format: :asciidoc, forms: nil, categories: nil, attributes: {}
90
+ def self.skim_string content, format: :asciidoc, forms: nil, categories: nil, attributes: {}, descriptions: false
86
91
  if format == :markdown
87
92
  config = Config.new(forms: forms || [:flat])
88
93
  MarkdownSkimmer.new.process(content, config: config)
94
+ elsif format == :ruby
95
+ RubySkimmer.new.process(content, config: Config.new(forms: forms || [:flat], descriptions: descriptions))
89
96
  else
90
97
  attrs = LOAD_OPTS[:attributes].merge(attributes)
91
98
  opts = LOAD_OPTS.merge(attributes: attrs)
@@ -113,6 +120,8 @@ module Sourcerer
113
120
  ext = File.extname(file_path).downcase
114
121
  if Sourcerer::MARKDOWN_EXTS.include?(ext)
115
122
  :markdown
123
+ elsif ext == '.rb'
124
+ :ruby
116
125
  else
117
126
  :asciidoc
118
127
  end