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.
- checksums.yaml +4 -4
- data/BILL_OF_MATERIALS.adoc +17 -0
- data/README.adoc +87 -7
- data/lib/sourcerer/_docs/partials/liquid-filters-by-kind.adoc +3227 -0
- data/lib/sourcerer/_docs/partials/liquid-filters-by-source.adoc +3155 -0
- data/lib/sourcerer/asciidoc.rb +10 -3
- data/lib/sourcerer/builder.rb +1 -1
- data/lib/sourcerer/jekyll/liquid/filters.rb +295 -0
- data/lib/sourcerer/jekyll.rb +7 -2
- data/lib/sourcerer/mark_down_grade.rb +181 -28
- data/lib/sourcerer/rendering.rb +9 -3
- data/lib/sourcerer/source_skim/config.rb +8 -2
- data/lib/sourcerer/source_skim/ruby_skimmer.rb +83 -0
- data/lib/sourcerer/source_skim/skimmer.rb +94 -1
- data/lib/sourcerer/source_skim.rb +11 -2
- data/lib/sourcerer/util/gem_uri.rb +34 -0
- data/lib/sourcerer/version.rb +1 -1
- data/lib/sourcerer.rb +0 -1
- data/specs/data/liquid-filters.yml +1314 -0
- metadata +26 -4
- data/specs/docs/frontmatter-reader_prd.adoc +0 -47
|
@@ -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:
|
|
142
|
-
class
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
186
|
+
# Definition term converter: formats term as italicized with colon.
|
|
153
187
|
class DtConverter < ReverseMarkdown::Converters::Base
|
|
154
188
|
def convert node, state={}
|
|
155
|
-
|
|
156
|
-
"
|
|
189
|
+
term_text = treat_children(node, state).strip
|
|
190
|
+
"**#{term_text}:**\n"
|
|
157
191
|
end
|
|
158
192
|
end
|
|
159
193
|
|
|
160
|
-
# Definition description converter:
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
'
|
|
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,
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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
|
data/lib/sourcerer/rendering.rb
CHANGED
|
@@ -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
|
|
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
|