asciisourcerer 0.3.1 → 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.
@@ -21,7 +21,9 @@ module Sourcerer
21
21
 
22
22
  @config = {
23
23
  preserve_heading_ids: true,
24
- strip_internal_links: false
24
+ strip_internal_links: false,
25
+ convert_tables_to_markdown: false,
26
+ convert_dls_to_markdown: true
25
27
  }
26
28
 
27
29
  class << self
@@ -32,6 +34,8 @@ module Sourcerer
32
34
  # Options:
33
35
  # preserve_heading_ids: (default: true) Include <a id="..."> anchors before headings
34
36
  # strip_internal_links: (default: false) Remove href from internal anchor links, keeping only text
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
35
39
  def self.bootstrap! options={}
36
40
  @config.merge!(options)
37
41
 
@@ -45,6 +49,7 @@ module Sourcerer
45
49
  register_blockquote_converter
46
50
  register_comment_converter
47
51
  register_link_converter
52
+ register_list_converters
48
53
  end
49
54
 
50
55
  # Enhanced Pre converter to handle additional code block language patterns
@@ -136,33 +141,67 @@ module Sourcerer
136
141
  end
137
142
  end
138
143
 
139
- # Definition list converter: preserve semantic list tags in output.
140
- 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
141
150
  def convert node, state={}
142
- body = node.children.map { |child| treat(child, state) }.join.strip
143
- attrs = []
144
- attrs << %( class="#{node['class']}") if node['class']
145
- attrs << %( role="#{node['role']}") if node['role']
146
- "<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)
147
183
  end
148
184
  end
149
185
 
150
- # Definition term converter: preserves <dt> with classes, converts content.
186
+ # Definition term converter: formats term as italicized with colon.
151
187
  class DtConverter < ReverseMarkdown::Converters::Base
152
188
  def convert node, state={}
153
- class_attr = node['class'] ? %( class="#{node['class']}") : ''
154
- "<dt#{class_attr}>#{treat_children(node, state)}</dt>\n"
189
+ term_text = treat_children(node, state).strip
190
+ "**#{term_text}:**\n"
155
191
  end
156
192
  end
157
193
 
158
- # 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.
159
197
  class DdConverter < ReverseMarkdown::Converters::Base
160
198
  def convert node, state={}
161
- content = treat_children(node, state)
162
- attrs = []
163
- attrs << %( class="#{node['class']}") if node['class']
164
- attrs << %( role="#{node['role']}") if node['role']
165
- "<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"
166
205
  end
167
206
  end
168
207
 
@@ -421,9 +460,97 @@ module Sourcerer
421
460
  end
422
461
 
423
462
  # Passthrough Tables: preserve HTML tables as-is (except admonition internals handled elsewhere).
463
+ # Tables with "to-markdown" class are converted via ReverseMarkdown instead.
464
+ # Supports both html5 (class on <table>) and html5s (class on parent <div class="table-block">).
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.
424
472
  class TablePassthrough < ReverseMarkdown::Converters::Base
425
- def convert node, _state={}
426
- "#{node.to_html}\n"
473
+ def initialize
474
+ super
475
+ @markdown_converter = ReverseMarkdown::Converters::Table.new
476
+ end
477
+
478
+ def convert node, state={}
479
+ return convert_hdlist(node, state) if hdlist_table?(node)
480
+
481
+ global_mode = Thread.current[:sourcerer_table_conversion_mode] || false
482
+
483
+ # Check for per-table classes (on table or parent wrapper)
484
+ has_to_markdown = check_class_on_node(node, 'to-markdown')
485
+ has_no_markdown = check_class_on_node(node, 'no-markdown')
486
+
487
+ # Also check parent <div class="table-block"> wrapper (html5s backend)
488
+ parent_div = node.parent
489
+ if parent_div && parent_div.name == 'div'
490
+ has_to_markdown ||= check_class_on_node(parent_div, 'to-markdown')
491
+ has_no_markdown ||= check_class_on_node(parent_div, 'no-markdown')
492
+ end
493
+
494
+ # Determine whether to convert (per-table classes override global mode)
495
+ should_convert = if has_no_markdown
496
+ false # .no-markdown always prevents conversion
497
+ elsif has_to_markdown
498
+ true # .to-markdown always forces conversion
499
+ else
500
+ global_mode # Use global table conversion mode
501
+ end
502
+
503
+ if should_convert
504
+ @markdown_converter.convert(node, state)
505
+ else
506
+ "#{node.to_html}\n"
507
+ end
508
+ end
509
+
510
+ private
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
+
552
+ def check_class_on_node node, class_name
553
+ node['class'].to_s.split.include?(class_name)
427
554
  end
428
555
  end
429
556
 
@@ -467,7 +594,7 @@ module Sourcerer
467
594
  is_checked = checkbox['checked'] || checkbox['data-item-complete'] == '1'
468
595
  # Remove the checkbox from the DOM so it doesn't get rendered again
469
596
  checkbox.remove
470
- is_checked ? '<!--CHECKBOX_CHECKED--> ' : '<!--CHECKBOX_UNCHECKED--> '
597
+ is_checked ? '- <!--CHECKBOX_CHECKED--> ' : '- <!--CHECKBOX_UNCHECKED--> '
471
598
  else
472
599
  prefix_for(node)
473
600
  end
@@ -484,9 +611,11 @@ module Sourcerer
484
611
  result = "#{indentation}#{prefix}#{content}\n"
485
612
 
486
613
  nested_lists.each do |nested_list|
487
- nested_state = state.merge(ol_count: state.fetch(:ol_count, 0) + 1)
488
- nested_md = treat(nested_list, nested_state).strip
489
- 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?
490
619
  end
491
620
 
492
621
  result
@@ -504,8 +633,11 @@ module Sourcerer
504
633
  end
505
634
 
506
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.
507
639
  length = state.fetch(:ol_count, 0)
508
- ' ' * [length - 1, 0].max
640
+ ' ' * [length - 1, 0].max
509
641
  end
510
642
  end # class LiWithNestedLists
511
643
 
@@ -528,8 +660,10 @@ module Sourcerer
528
660
  end
529
661
 
530
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>.
531
665
  def self.register_dl_converters
532
- ReverseMarkdown::Converters.register :dl, DlConverter.new
666
+ ReverseMarkdown::Converters.register :dl, DlPassthrough.new
533
667
  ReverseMarkdown::Converters.register :dt, DtConverter.new
534
668
  ReverseMarkdown::Converters.register :dd, DdConverter.new
535
669
  end
@@ -576,6 +710,11 @@ module Sourcerer
576
710
  ReverseMarkdown::Converters.register :a, LinkConverter.new
577
711
  end
578
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
+
579
718
  # Normalize block titles so escaped inline emphasis from html5s is converted
580
719
  # to markdown emphasis consistently with html5 conversions.
581
720
  def self.normalize_block_title text
@@ -606,18 +745,50 @@ module Sourcerer
606
745
  end
607
746
 
608
747
  # Convert HTML into Markdown with MarkDownGrade converters.
748
+ # Options include:
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)
609
751
  def self.convert_html html, options={}
610
752
  bootstrap! unless @setup_complete
611
753
  @setup_complete = true
612
754
 
613
- normalized_html = normalize_html_for_markdown(html.to_s)
614
- markdown = ReverseMarkdown.convert(normalized_html, options)
615
- markdown = markdown.gsub(/(\*\*[^\n]+\*\* \n)\n+(?=\S)/, '\\1')
616
- markdown = markdown.gsub(/<figcaption>\s+/, '<figcaption>')
617
- markdown = markdown.gsub(%r{\s+</figcaption>}, '</figcaption>')
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
760
+
761
+ begin
762
+ normalized_html = normalize_html_for_markdown(html.to_s)
763
+ markdown = ReverseMarkdown.convert(normalized_html, options)
764
+ markdown = markdown.gsub(/(\*\*[^\n]+\*\* \n)\n+(?=\S)/, '\\1')
765
+ markdown = markdown.gsub(/<figcaption>\s+/, '<figcaption>')
766
+ markdown = markdown.gsub(%r{\s+</figcaption>}, '</figcaption>')
767
+ markdown = normalize_blank_lines(markdown)
768
+
769
+ # Replace checkbox markers: handle indented list items and maintain correct dash placement
770
+ replace_checkbox_markers(markdown)
771
+ ensure
772
+ Thread.current[:sourcerer_table_conversion_mode] = nil
773
+ Thread.current[:sourcerer_dl_conversion_mode] = nil
774
+ end
775
+ end
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
618
784
 
619
- markdown.gsub('<!--CHECKBOX_CHECKED-->', '- [x]')
620
- .gsub('<!--CHECKBOX_UNCHECKED-->', '- [ ]')
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[ ] ')
621
792
  end
622
793
 
623
794
  def self.convert html, options={}
@@ -629,6 +800,7 @@ module Sourcerer
629
800
  fragment = Nokogiri::HTML::DocumentFragment.parse(html_body)
630
801
  normalize_abstract_nodes!(fragment)
631
802
  normalize_footnote_nodes!(fragment)
803
+ clean_html5s_tables!(fragment)
632
804
  fragment.to_html
633
805
  end
634
806
 
@@ -682,10 +854,126 @@ module Sourcerer
682
854
  raw_id.empty? ? nil : raw_id
683
855
  end
684
856
 
857
+ # Clean HTML5s table artifacts: remove colgroup elements and class/style attributes from cells.
858
+ # This normalizes tables generated by the asciidoctor-html5s backend for safer Markdown conversion.
859
+ #
860
+ # @param fragment [Nokogiri::HTML::DocumentFragment] The HTML fragment to clean.
861
+ # @return [void] Modifies fragment in place.
862
+ def self.clean_html5s_tables! fragment
863
+ # Remove all colgroup elements
864
+ fragment.css('colgroup').each(&:remove)
865
+
866
+ # Remove class and style attributes from table cells and rows
867
+ fragment.css('td, th, tr').each do |cell|
868
+ cell.delete('class')
869
+ cell.delete('style')
870
+ end
871
+ end
872
+
873
+ # Determine the effective table conversion mode from options, frontmatter, or global config.
874
+ #
875
+ # Precedence:
876
+ # 1. Explicit convert_tables_to_markdown option in parameters
877
+ # 2. Document-level setting from frontmatter (YAML) or page attributes (AsciiDoc)
878
+ # 3. Global config setting
879
+ #
880
+ # @param html_body [String] The HTML document body.
881
+ # @param options [Hash] Optional override.
882
+ # @return [Boolean] Whether tables should be converted to markdown by default.
883
+ def self.determine_table_conversion_mode html_body, options
884
+ # Explicit option takes precedence
885
+ return options[:convert_tables_to_markdown] if options.key?(:convert_tables_to_markdown)
886
+
887
+ # Extract from document metadata
888
+ document_mode = extract_table_conversion_mode_from_html(html_body)
889
+ return document_mode unless document_mode.nil?
890
+
891
+ # Fall back to global config
892
+ @config[:convert_tables_to_markdown]
893
+ end
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
+
927
+ # Extract table conversion mode from document frontmatter or page attributes.
928
+ #
929
+ # Checks for:
930
+ # - YAML frontmatter key: tables-to-markdown
931
+ # - AsciiDoc page attribute: page-tables-to-markdown
932
+ #
933
+ # @param html_body [String] The HTML document body.
934
+ # @return [Boolean, nil] The setting if found, nil otherwise.
935
+ def self.extract_table_conversion_mode_from_html html_body
936
+ # Try to extract YAML frontmatter
937
+ frontmatter = Sourcerer::YamlFrontmatter.extract(html_body)
938
+ return string_to_boolean(frontmatter['tables-to-markdown']) if frontmatter.key?('tables-to-markdown')
939
+
940
+ # Try to find page-tables-to-markdown in HTML comments or metadata
941
+ # (This would be set by AsciiDoc's page attributes)
942
+ if html_body.include?('page-tables-to-markdown')
943
+ # Look for data attributes or comments that might encode this
944
+ if html_body.match?(/page-tables-to-markdown['"]?\s*[:=]\s*['"]*true/i)
945
+ return true
946
+ elsif html_body.match?(/page-tables-to-markdown['"]?\s*[:=]\s*['"]*false/i)
947
+ return false
948
+ end
949
+ end
950
+
951
+ nil
952
+ end
953
+
954
+ # Convert a string representation to a boolean value.
955
+ #
956
+ # @param value [String, Boolean, nil] The value to convert.
957
+ # @return [Boolean, nil] Boolean if value is truthy string, nil if unclear.
958
+ def self.string_to_boolean value
959
+ case value.to_s.downcase.strip
960
+ when 'true', '1', 'yes', 'on'
961
+ true
962
+ when 'false', '0', 'no', 'off', ''
963
+ false
964
+ end
965
+ end
966
+
685
967
  private_class_method :normalize_html_for_markdown,
686
968
  :normalize_abstract_nodes!,
687
969
  :normalize_footnote_nodes!,
688
- :canonical_footnote_anchor_id
970
+ :canonical_footnote_anchor_id,
971
+ :clean_html5s_tables!,
972
+ :determine_table_conversion_mode,
973
+ :extract_table_conversion_mode_from_html,
974
+ :determine_dl_conversion_mode,
975
+ :extract_dl_conversion_mode_from_html,
976
+ :string_to_boolean
689
977
  end # module MarkDownGrade
690
978
  end # module Sourcerer
691
979
 
@@ -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
@@ -86,7 +92,7 @@ module Sourcerer
86
92
  # @return [void]
87
93
  def self.render_with_converter render_entry
88
94
  data_file = render_entry[:data]
89
- out_file = render_entry[:out]
95
+ out_file = render_entry[:out]
90
96
  raise ArgumentError, 'render entry missing :data' unless data_file
91
97
  raise ArgumentError, 'render entry missing :out' unless out_file
92
98
 
@@ -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