markdown_composer 0.7.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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/ROADMAP.md +80 -0
  6. data/docs/_md_composer_architecture.md +50 -0
  7. data/docs/_md_composer_cheatsheet.md +72 -0
  8. data/docs/_md_composer_concepts.md +64 -0
  9. data/docs/_md_composer_dev_guide.md +55 -0
  10. data/docs/_md_composer_getting_started.md +114 -0
  11. data/docs/_md_composer_readme.md +93 -0
  12. data/docs/_md_composer_user_guide.md +65 -0
  13. data/docs/ai/md_composer_ai_audit.md +35 -0
  14. data/docs/ai/md_composer_ai_canonical_docs.md +44 -0
  15. data/docs/ai/md_composer_ai_source_map.md +39 -0
  16. data/docs/compose/md_composer_compose_actions.md +338 -0
  17. data/docs/compose/md_composer_compose_anatomy.md +156 -0
  18. data/docs/compose/md_composer_compose_buffer.md +81 -0
  19. data/docs/compose/md_composer_compose_examples.md +31 -0
  20. data/docs/compose/md_composer_compose_include.md +136 -0
  21. data/docs/compose/md_composer_compose_select.md +198 -0
  22. data/docs/compose/md_composer_compose_sources.md +161 -0
  23. data/docs/compose/md_composer_compose_targets.md +194 -0
  24. data/docs/examples/md_composer_example_basic_compose.md +57 -0
  25. data/docs/examples/md_composer_example_buffer_target_actions.md +83 -0
  26. data/docs/examples/md_composer_example_fixtures.md +62 -0
  27. data/docs/examples/md_composer_example_html_output.md +50 -0
  28. data/docs/examples/md_composer_example_modify.md +77 -0
  29. data/docs/examples/md_composer_example_multi_row_compose.md +67 -0
  30. data/docs/examples/md_composer_example_ruby_plans.md +62 -0
  31. data/docs/examples/md_composer_example_structured_data.md +68 -0
  32. data/docs/examples/md_composer_example_transforms.md +68 -0
  33. data/docs/examples/md_composer_example_yaml_json_rows.md +56 -0
  34. data/docs/examples/md_composer_examples_readme.md +45 -0
  35. data/docs/examples/md_composer_runnable_examples.md +374 -0
  36. data/docs/examples/md_composer_source_ruby_dsl.md +88 -0
  37. data/docs/reference/md_composer_nested.md +170 -0
  38. data/docs/reference/md_composer_reference_api.md +71 -0
  39. data/docs/reference/md_composer_reference_capabilities.md +63 -0
  40. data/docs/reference/md_composer_reference_diagnostics.md +54 -0
  41. data/docs/reference/md_composer_reference_plan_schema.md +75 -0
  42. data/docs/reference/md_composer_reference_registries.md +63 -0
  43. data/docs/reference/md_composer_take.md +221 -0
  44. data/docs/reference/md_composer_unit_tokens.md +228 -0
  45. data/docs/reference/md_composer_where.md +227 -0
  46. data/docs/transform/md_composer_transform_anatomy.md +112 -0
  47. data/docs/transform/md_composer_transform_examples.md +30 -0
  48. data/docs/transform/md_composer_transform_modes.md +83 -0
  49. data/docs/transform/md_composer_transform_options.md +142 -0
  50. data/docs/transform/md_composer_transform_scope.md +97 -0
  51. data/docs/transform/md_composer_transform_transforms.md +99 -0
  52. data/examples/README.md +20 -0
  53. data/examples/advanced_composer.rb +207 -0
  54. data/examples/basic_compose.rb +24 -0
  55. data/examples/complex_composer.rb +235 -0
  56. data/examples/example_support.rb +18 -0
  57. data/examples/fixtures/current.md +179 -0
  58. data/examples/fixtures/faq.md +58 -0
  59. data/examples/fixtures/guide.md +62 -0
  60. data/examples/fixtures/site_intro.md +29 -0
  61. data/examples/fixtures/source.html +22 -0
  62. data/examples/html_input.rb +26 -0
  63. data/examples/output/advanced_composer.md +76 -0
  64. data/examples/output/basic_compose.md +25 -0
  65. data/examples/output/complex_composer.md +85 -0
  66. data/examples/output/html_input.md +4 -0
  67. data/examples/output/source_list_dsl.md +126 -0
  68. data/examples/output/standard_composer.md +46 -0
  69. data/examples/output/standard_sources_buffer.md +31 -0
  70. data/examples/output/yaml_plan.md +43 -0
  71. data/examples/plans/basic.yml +20 -0
  72. data/examples/source_list_dsl.rb +41 -0
  73. data/examples/standard_composer.rb +42 -0
  74. data/examples/standard_sources_buffer.rb +62 -0
  75. data/examples/yaml_plan.rb +17 -0
  76. data/lib/markdown_composer/capabilities.rb +223 -0
  77. data/lib/markdown_composer/composition_buffer.rb +378 -0
  78. data/lib/markdown_composer/data_path.rb +313 -0
  79. data/lib/markdown_composer/diagnostics.rb +63 -0
  80. data/lib/markdown_composer/document_index/html_parser.rb +84 -0
  81. data/lib/markdown_composer/document_index/markdown_parser.rb +338 -0
  82. data/lib/markdown_composer/document_index.rb +94 -0
  83. data/lib/markdown_composer/executor.rb +284 -0
  84. data/lib/markdown_composer/markdown_renderer.rb +105 -0
  85. data/lib/markdown_composer/plan.rb +436 -0
  86. data/lib/markdown_composer/plan_builder.rb +111 -0
  87. data/lib/markdown_composer/registries/action_entries.rb +26 -0
  88. data/lib/markdown_composer/registries/condition_entries.rb +58 -0
  89. data/lib/markdown_composer/registries/registry.rb +69 -0
  90. data/lib/markdown_composer/registries/source_entries.rb +18 -0
  91. data/lib/markdown_composer/registries/support_values.rb +23 -0
  92. data/lib/markdown_composer/registries/take_entries.rb +31 -0
  93. data/lib/markdown_composer/registries/take_registry.rb +18 -0
  94. data/lib/markdown_composer/registries/target_entries.rb +40 -0
  95. data/lib/markdown_composer/registries/unit_token_entries.rb +62 -0
  96. data/lib/markdown_composer/registries/where_registry.rb +84 -0
  97. data/lib/markdown_composer/registries.rb +46 -0
  98. data/lib/markdown_composer/result.rb +34 -0
  99. data/lib/markdown_composer/selection_resolver.rb +181 -0
  100. data/lib/markdown_composer/source.rb +57 -0
  101. data/lib/markdown_composer/source_list_builder.rb +47 -0
  102. data/lib/markdown_composer/take.rb +129 -0
  103. data/lib/markdown_composer/transform_options.rb +66 -0
  104. data/lib/markdown_composer/transform_runner/content_placement.rb +63 -0
  105. data/lib/markdown_composer/transform_runner/field_interpolator.rb +213 -0
  106. data/lib/markdown_composer/transform_runner/heading_numbering.rb +106 -0
  107. data/lib/markdown_composer/transform_runner/scope_resolver.rb +87 -0
  108. data/lib/markdown_composer/transform_runner.rb +264 -0
  109. data/lib/markdown_composer/transforms/default_entries.rb +31 -0
  110. data/lib/markdown_composer/transforms/registry.rb +11 -0
  111. data/lib/markdown_composer/validator.rb +378 -0
  112. data/lib/markdown_composer/value_object.rb +15 -0
  113. data/lib/markdown_composer/version.rb +5 -0
  114. data/lib/markdown_composer/where.rb +313 -0
  115. data/lib/markdown_composer.rb +114 -0
  116. metadata +260 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class SourceListBuilder
5
+ def initialize
6
+ @sources = []
7
+ end
8
+
9
+ def current(markdown = nil, key: "current", html: nil, title: nil, preferred_format: :markdown, metadata: {})
10
+ add(type: "current", key: key, markdown: markdown, html: html, title: title, preferred_format: preferred_format, metadata: metadata)
11
+ end
12
+
13
+ def explicit(key, markdown = nil, html: nil, title: nil, preferred_format: :markdown, metadata: {})
14
+ add(type: "explicit", key: key, markdown: markdown, html: html, title: title, preferred_format: preferred_format, metadata: metadata)
15
+ end
16
+
17
+ def inherited(key, markdown = nil, html: nil, title: nil, preferred_format: :markdown, metadata: {})
18
+ add(type: "inherited", key: key, markdown: markdown, html: html, title: title, preferred_format: preferred_format, metadata: metadata)
19
+ end
20
+
21
+ def to_a
22
+ @sources.map(&:dup)
23
+ end
24
+
25
+ private
26
+
27
+ def add(type:, key:, markdown:, html:, title:, preferred_format:, metadata:)
28
+ normalized_key = normalize_key(key, type)
29
+ @sources << {
30
+ "key" => normalized_key,
31
+ "type" => type.to_s,
32
+ "title" => title,
33
+ "markdown" => markdown,
34
+ "html" => html,
35
+ "preferred_format" => preferred_format,
36
+ "metadata" => metadata
37
+ }.compact
38
+ end
39
+
40
+ def normalize_key(key, type)
41
+ normalized = key.to_s
42
+ raise ArgumentError, "#{type} source requires a key" if normalized.empty?
43
+
44
+ normalized
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ module Take
5
+ module_function
6
+
7
+ def parse(value)
8
+ text = value.to_s.strip
9
+ text = text[1..-2] if text.start_with?("[") && text.end_with?("]")
10
+ return { "all" => true } if text.empty? || text == "all"
11
+
12
+ clauses = text.split(";").map(&:strip)
13
+ clauses.each_with_object({}) do |clause, hash|
14
+ case clause
15
+ when /\Aodd\z/
16
+ hash["odd"] = true
17
+ when /\Aeven\z/
18
+ hash["even"] = true
19
+ when /\A(\d+|-?\d+)\.\.(last|-?\d+|\d+)\z/
20
+ (hash["ranges"] ||= []) << { "from" => integer(Regexp.last_match(1)), "to" => integer(Regexp.last_match(2)) }
21
+ when /\A([^:]+):(.+)\z/
22
+ key = Regexp.last_match(1).strip
23
+ raw = Regexp.last_match(2).strip
24
+ case key
25
+ when "position", "except"
26
+ hash[key] = raw.split(",").map { |part| integer(part.strip) }
27
+ when "range"
28
+ from, to = raw.split("..", 2)
29
+ (hash["ranges"] ||= []) << { "from" => integer(from), "to" => integer(to) }
30
+ else
31
+ hash[key] = integer(raw)
32
+ end
33
+ else
34
+ hash[clause] = true
35
+ end
36
+ end
37
+ end
38
+
39
+ def apply(items, take, diagnostics: nil, path: nil, seed: 0)
40
+ take = parse(take) if take.is_a?(String) || take.is_a?(Symbol)
41
+ take ||= { "all" => true }
42
+ errors = validate(take)
43
+ errors.each { |message| diagnostics&.error("take.invalid", message, path: path) }
44
+ return [] if errors.any?
45
+ return items.dup if take.empty? || take["all"]
46
+
47
+ selected_indexes = []
48
+ count = items.length
49
+
50
+ selected_indexes.concat((0...[take["first"].to_i, count].min).to_a) if take["first"]
51
+ selected_indexes.concat(((count - take["last"].to_i)...count).to_a) if take["last"]
52
+ selected_indexes.concat(Array(take["position"]).map { |position| resolve_position(position, count) }.compact) if take["position"]
53
+ Array(take["ranges"]).each do |range|
54
+ from = resolve_position(range["from"], count)
55
+ to = resolve_position(range["to"], count)
56
+ selected_indexes.concat((from..to).to_a) if from && to && from <= to
57
+ end
58
+
59
+ if take["skip"]
60
+ selected_indexes = (take["skip"].to_i...count).to_a
61
+ end
62
+ if take["skip_last"]
63
+ selected_indexes = (0...[count - take["skip_last"].to_i, 0].max).to_a
64
+ end
65
+ selected_indexes = (0...count).select { |index| (index + 1) % take["every"].to_i == 0 } if take["every"]
66
+ selected_indexes = (0...count).select { |index| (index + 1).odd? } if take["odd"]
67
+ selected_indexes = (0...count).select { |index| (index + 1).even? } if take["even"]
68
+ selected_indexes = (0...count).to_a - Array(take["except"]).map { |position| resolve_position(position, count) }.compact if take["except"]
69
+ selected_indexes = percent_indexes(count, take["top_percent"].to_i, :top) if take["top_percent"]
70
+ selected_indexes = percent_indexes(count, take["bottom_percent"].to_i, :bottom) if take["bottom_percent"]
71
+ selected_indexes = middle_indexes(count, take["middle"].to_i) if take["middle"]
72
+ selected_indexes = middle_indexes(count, (count * take["middle_percent"].to_i / 100.0).ceil) if take["middle_percent"]
73
+ selected_indexes = alternate_indexes(count, take["alternate"].to_i) if take["alternate"]
74
+ selected_indexes = (0...count).to_a.sample(take["random"].to_i, random: Random.new(seed)).sort if take["random"]
75
+
76
+ selected_indexes.uniq.sort.map { |index| items[index] }.compact
77
+ end
78
+
79
+ def validate(take)
80
+ errors = []
81
+ take.each do |key, value|
82
+ unless Registries.default.take.key?(key.to_sym)
83
+ errors << "Unknown take operator #{key.inspect}"
84
+ next
85
+ end
86
+
87
+ case key
88
+ when "first", "last", "skip", "skip_last", "every", "top_percent", "bottom_percent", "middle", "middle_percent", "alternate", "random"
89
+ errors << "#{key} must be positive" if value.to_i <= 0
90
+ when "position", "except"
91
+ Array(value).each { |position| errors << "#{key} cannot include 0" if position.to_i.zero? }
92
+ when "ranges"
93
+ Array(value).each do |range|
94
+ errors << "range is missing from/to" unless range.key?("from") && range.key?("to")
95
+ errors << "range cannot include 0" if range["from"].to_i.zero? || range["to"].to_i.zero?
96
+ end
97
+ end
98
+ end
99
+ errors
100
+ end
101
+
102
+ def integer(value)
103
+ value.to_s == "last" ? -1 : value.to_i
104
+ end
105
+
106
+ def resolve_position(position, count)
107
+ value = position.to_i
108
+ return nil if value.zero?
109
+
110
+ index = value.positive? ? value - 1 : count + value
111
+ index if index >= 0 && index < count
112
+ end
113
+
114
+ def percent_indexes(count, percent, direction)
115
+ selected_count = (count * percent / 100.0).ceil
116
+ direction == :top ? (0...selected_count).to_a : ((count - selected_count)...count).to_a
117
+ end
118
+
119
+ def middle_indexes(count, selected_count)
120
+ selected_count = [ selected_count, count ].min
121
+ start = [(count - selected_count) / 2, 0].max
122
+ (start...(start + selected_count)).to_a
123
+ end
124
+
125
+ def alternate_indexes(count, group_size)
126
+ (0...count).select { |index| ((index / group_size) % 2).zero? }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ module TransformOptions
5
+ module_function
6
+
7
+ def required(transform, mode)
8
+ case [ transform.to_s, mode.to_s ]
9
+ when [ "replace_text", "literal" ], [ "replace_text", "word" ], [ "replace_text", "regex" ]
10
+ %w[from to]
11
+ when [ "links", "rewrite_url" ]
12
+ %w[from to]
13
+ when [ "heading_numbers", "add" ], [ "heading_numbers", "rebuild" ]
14
+ %w[levels]
15
+ when [ "heading_levels", "promote" ], [ "heading_levels", "demote" ]
16
+ %w[by]
17
+ when [ "heading_levels", "normalise" ]
18
+ %w[to]
19
+ when [ "remove_empty", "remove" ]
20
+ %w[unit]
21
+ when [ "insert_before", "insert" ], [ "insert_after", "insert" ], [ "prepend_content", "insert" ], [ "append_content", "insert" ], [ "replace_content", "replace" ]
22
+ %w[content as]
23
+ when [ "order", "target_order" ]
24
+ %w[target]
25
+ else
26
+ []
27
+ end
28
+ end
29
+
30
+ def optional(transform, mode, adapter_option_keys: [])
31
+ base = case [ transform.to_s, mode.to_s ]
32
+ when [ "heading_numbers", "keep" ] then %w[levels format]
33
+ when [ "heading_numbers", "strip" ] then %w[levels pattern]
34
+ when [ "heading_numbers", "add" ] then %w[start format skip_if_present]
35
+ when [ "heading_numbers", "rebuild" ] then %w[start format exclude restart_at]
36
+ when [ "replace_text", "literal" ] then %w[case_sensitive limit whole_node]
37
+ when [ "replace_text", "word" ] then %w[case_sensitive limit]
38
+ when [ "replace_text", "regex" ] then %w[case_sensitive limit]
39
+ when [ "links", "keep" ] then []
40
+ when [ "links", "unwrap" ] then %w[keep_text]
41
+ when [ "links", "remove" ] then %w[keep_text]
42
+ when [ "links", "rewrite_url" ] then %w[match case_sensitive]
43
+ when [ "links", "nofollow" ], [ "links", "target_blank" ] then []
44
+ when [ "heading_levels", "promote" ], [ "heading_levels", "demote" ] then %w[min_level max_level]
45
+ when [ "heading_levels", "normalise" ] then []
46
+ when [ "remove_empty", "remove" ] then %w[trim ignore_nbsp ignore_comments]
47
+ when [ "insert_before", "insert" ], [ "insert_after", "insert" ] then %w[dedupe parse_as]
48
+ when [ "prepend_content", "insert" ], [ "append_content", "insert" ] then %w[target dedupe parse_as]
49
+ when [ "replace_content", "replace" ] then %w[parse_as]
50
+ when [ "remove_content", "remove" ] then []
51
+ when [ "dedupe", "normalised_text" ] then %w[case_sensitive]
52
+ when [ "dedupe", "source_node_id" ] then []
53
+ when [ "order", "action_order" ], [ "order", "source_order" ] then []
54
+ when [ "order", "target_order" ] then []
55
+ when [ "sanitise", "block_safe" ], [ "sanitise", "text_only" ], [ "sanitise", "links_unwrapped" ], [ "sanitise", "strict" ] then %w[profile]
56
+ else
57
+ %w[levels from to by unit profile name content as target parse_as case_sensitive limit dedupe format pattern start skip_if_present exclude restart_at keep_text match min_level max_level trim ignore_nbsp ignore_comments whole_node]
58
+ end
59
+ transform.to_s == "adapter" ? base + Array(adapter_option_keys).map(&:to_s) : base
60
+ end
61
+
62
+ def allowed(transform, mode, adapter_option_keys: [])
63
+ (required(transform, mode) + optional(transform, mode, adapter_option_keys: adapter_option_keys)).uniq
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class TransformRunner
5
+ module ContentPlacement
6
+ private
7
+
8
+ def current_blocks
9
+ buffer.markdown.split(/\n{2,}/).map { |block| "#{block.strip}\n" }.reject { |block| block.strip.empty? }
10
+ end
11
+
12
+ def duplicate_insert?(content, transform, inserted_content)
13
+ return false unless transform.dig("options", "dedupe")
14
+
15
+ inserted_content[content.strip] || buffer.markdown.include?(content.strip)
16
+ end
17
+
18
+ def insertion_index_for(transform, placement, range, unit, path:)
19
+ target = transform.dig("options", "target")
20
+ if target && %i[prepend append].include?(placement)
21
+ target_range = nested_target_ranges(target, unit, path: path).yield_self { |ranges| placement == :prepend ? ranges.first : ranges.last }
22
+ return placement == :prepend ? target_range.begin - 1 : target_range.end if target_range
23
+ end
24
+
25
+ case placement
26
+ when :before then range.begin - 1
27
+ when :after then range.end
28
+ when :prepend then range.begin
29
+ when :append then range.end
30
+ end
31
+ end
32
+
33
+ def normalised_dedupe_key(value, case_sensitive:)
34
+ key = value.to_s.gsub(/\s+/, " ").strip
35
+ case_sensitive ? key : key.downcase
36
+ end
37
+
38
+ def spaced_insert_content(content, transform, lines, insertion_index)
39
+ return content unless paragraph_like_insert?(content, transform)
40
+
41
+ text = content.dup
42
+ previous_line = lines[insertion_index - 1].to_s
43
+ next_line = lines[insertion_index].to_s
44
+ text = "\n#{text}" if !previous_line.empty? && !previous_line.strip.empty? && !previous_line.match?(/\A\s{0,3}\#{1,6}\s+/)
45
+ text = "#{text}\n" if !next_line.empty? && !next_line.strip.empty?
46
+ text
47
+ end
48
+
49
+ def paragraph_like_insert?(content, transform)
50
+ as = transform.dig("options", "as").to_s
51
+ return false if as.start_with?("heading_") || as == "comment"
52
+
53
+ !raw_markdown_block?(content)
54
+ end
55
+
56
+ def ensure_block(content)
57
+ text = content.to_s
58
+ text = "#{text}\n" unless text.end_with?("\n")
59
+ text
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class TransformRunner
5
+ module FieldInterpolator
6
+ private
7
+
8
+ def content_for(transform, unit: nil, index: nil)
9
+ content = render_content_template(transform.dig("options", "content").to_s, unit, index: index)
10
+ as = transform.dig("options", "as").to_s
11
+ parse_as = transform.dig("options", "parse_as").to_s
12
+ parse_as = "markdown" if parse_as.empty?
13
+ content = escape_text_content(content) if parse_as == "text"
14
+ return content if parse_as == "markdown" && raw_markdown_block?(content)
15
+ return "#{content}\n" unless as.start_with?("heading_")
16
+
17
+ level = as[/heading_(\d)/, 1].to_i
18
+ "#{"#" * level} #{content}\n"
19
+ end
20
+
21
+ def raw_markdown_block?(content)
22
+ content.start_with?("#", "-", ">", "|", "```", "<!--")
23
+ end
24
+
25
+ def escape_text_content(content)
26
+ content.to_s.lines.map do |line|
27
+ line.sub(/\A(\s*)([#>\-|]|```)/, "\\1\\\\\\2")
28
+ end.join
29
+ end
30
+
31
+ def render_content_template(content, unit, index: nil)
32
+ return content unless unit
33
+
34
+ content.gsub(/field\[([a-z_][a-z0-9_]*(?::[a-z0-9_]+)?(?:\.[a-z_][a-z0-9_]*(?::[a-z0-9_]+)?)*(?:\[[^\]]+\])?)\]/) do
35
+ field_value(Regexp.last_match(1), unit, index: index).to_s
36
+ end
37
+ end
38
+
39
+ def template_title(unit)
40
+ raw = unit.respond_to?(:raw) ? unit.raw.to_s.lines.first.to_s : ""
41
+ if raw.match?(/\A\#{1,6}\s+/)
42
+ return raw.sub(/\A\#{1,6}\s+/, "").strip.sub(/\A\d+(?:\.\d+)*\.?\s+/, "")
43
+ end
44
+
45
+ unit.respond_to?(:title_text) ? unit.title_text.to_s : unit.text.to_s
46
+ end
47
+
48
+ def field_value(reference, unit, index: nil)
49
+ reference, take = split_field_take(reference)
50
+ parts = reference.to_s.split(".")
51
+ value = if parts.length == 1
52
+ unit_field(unit, parts.first, index: index)
53
+ else
54
+ scoped_unit_field(parts, unit, index: index)
55
+ end
56
+ take ? apply_field_take(value, take) : value
57
+ end
58
+
59
+ def split_field_take(reference)
60
+ text = reference.to_s
61
+ return [ text, nil ] unless text =~ /\A(.+)\[([^\]]+)\]\z/
62
+
63
+ [ Regexp.last_match(1), Regexp.last_match(2) ]
64
+ end
65
+
66
+ def apply_field_take(value, take)
67
+ words = value.to_s.scan(/\S+/)
68
+ Take.apply(words, Take.parse(take), diagnostics: diagnostics, path: "field").join(" ")
69
+ end
70
+
71
+ def scoped_unit_field(parts, unit, index: nil)
72
+ case parts.first
73
+ when "current"
74
+ unit_field(unit, parts[1], index: index)
75
+ when "section"
76
+ section = section_reference(parts[1..], unit)
77
+ unit_field(section, parts.last, index: index)
78
+ when "parent_section"
79
+ section = containing_section(unit)
80
+ unit_field(parent_section(section), parts[1], index: index)
81
+ else
82
+ ""
83
+ end
84
+ end
85
+
86
+ def section_reference(parts, unit)
87
+ section = containing_section(unit)
88
+ return section if parts.empty? || !parts.first
89
+
90
+ remaining = parts.dup
91
+ while remaining.first == "parent"
92
+ section = parent_section(section)
93
+ remaining.shift
94
+ end
95
+ if remaining.first&.start_with?("ancestor:")
96
+ heading_token = remaining.first.split(":", 2).last
97
+ section = ancestor_section(section, heading_token)
98
+ end
99
+ section
100
+ end
101
+
102
+ def unit_field(unit, field, index: nil)
103
+ return "" unless unit
104
+
105
+ case field.to_s
106
+ when "title"
107
+ return unit.attributes["title"].to_s if unit.respond_to?(:attributes) && unit.attributes["title"]
108
+
109
+ template_title(unit)
110
+ when "text"
111
+ unit.is_a?(ComposerSection) && unit.level.to_i.positive? ? unit.title_text.to_s : unit.text.to_s
112
+ when "source_text"
113
+ unit.respond_to?(:raw) ? unit.raw.to_s : unit_field(unit, "text", index: index)
114
+ when "type"
115
+ unit.respond_to?(:type) ? unit.type.to_s : section_type(unit)
116
+ when "index", "item_index"
117
+ attribute = attribute_field(unit, field.to_s)
118
+ attribute.empty? ? index.to_s : attribute
119
+ when "start_line"
120
+ unit.respond_to?(:start_line) ? unit.start_line.to_s : ""
121
+ when "end_line"
122
+ unit.respond_to?(:end_line) ? unit.end_line.to_s : ""
123
+ when "level"
124
+ unit.respond_to?(:level) ? unit.level.to_s : ""
125
+ when "number"
126
+ visible_heading_number(unit)
127
+ when "item_count"
128
+ attribute = attribute_field(unit, "item_count")
129
+ attribute.empty? ? item_count(unit).to_s : attribute
130
+ when "ordered"
131
+ value = attribute_field(unit, "ordered")
132
+ value == "" ? (unit.respond_to?(:type) && unit.type == "ordered_list").to_s : value.to_s
133
+ when "row_count"
134
+ attribute = attribute_field(unit, "row_count")
135
+ attribute.empty? ? table_row_count(unit).to_s : attribute
136
+ when "column_count"
137
+ attribute = attribute_field(unit, "column_count")
138
+ attribute.empty? ? table_column_count(unit).to_s : attribute
139
+ when "row_index", "column_index"
140
+ unit.respond_to?(:attributes) ? unit.attributes[field.to_s].to_s : ""
141
+ when "section"
142
+ unit.respond_to?(:attributes) ? unit.attributes["section"].to_s : ""
143
+ else
144
+ unit.respond_to?(:attributes) ? unit.attributes[field.to_s].to_s : ""
145
+ end
146
+ end
147
+
148
+ def attribute_field(unit, field)
149
+ unit.respond_to?(:attributes) ? unit.attributes[field].to_s : ""
150
+ end
151
+
152
+ def containing_section(unit)
153
+ return unit if unit.is_a?(ComposerSection)
154
+ return nil unless unit.respond_to?(:start_line)
155
+
156
+ buffer.index.sections.select { |section| section.start_line <= unit.start_line && section.end_line >= unit.end_line }
157
+ .max_by(&:level)
158
+ end
159
+
160
+ def parent_section(section)
161
+ return nil unless section&.parent_section_id
162
+
163
+ buffer.index.sections.find { |candidate| candidate.id == section.parent_section_id }
164
+ end
165
+
166
+ def ancestor_section(section, heading_token)
167
+ level = heading_level(heading_token)
168
+ current = section
169
+ while current
170
+ return current if current.level == level
171
+
172
+ current = parent_section(current)
173
+ end
174
+ nil
175
+ end
176
+
177
+ def section_type(section)
178
+ return "" unless section.respond_to?(:level)
179
+
180
+ section.level.to_i.positive? ? "heading_#{section.level}_section" : "output"
181
+ end
182
+
183
+ def visible_heading_number(unit)
184
+ return "" unless unit.respond_to?(:raw)
185
+
186
+ unit.raw.to_s.lines.first.to_s.sub(/\A\#{1,6}\s+/, "")[/\A\d+(?:\.\d+)*\.?/].to_s
187
+ end
188
+
189
+ def item_count(unit)
190
+ return 0 unless unit.respond_to?(:raw)
191
+
192
+ unit.raw.to_s.lines.count { |line| line.match?(/\A\s*(?:[-*+]|\d+\.)\s+/) }
193
+ end
194
+
195
+ def table_row_count(unit)
196
+ return 0 unless unit.respond_to?(:raw)
197
+
198
+ unit.raw.to_s.lines.count do |line|
199
+ line.match?(/\A\|.*\|\s*$/) && !line.match?(/\A\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/)
200
+ end
201
+ end
202
+
203
+ def table_column_count(unit)
204
+ return 0 unless unit.respond_to?(:raw)
205
+
206
+ row = unit.raw.to_s.lines.find { |line| line.match?(/\A\|.*\|\s*$/) }
207
+ return 0 unless row
208
+
209
+ row.strip.sub(/\A\|/, "").sub(/\|\z/, "").split("|").length
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class TransformRunner
5
+ module HeadingNumbering
6
+ private
7
+
8
+ def heading_levels(value)
9
+ Array(value).map { |item| heading_level(item) }.compact
10
+ end
11
+
12
+ def heading_level(value)
13
+ token = Registries.default.unit_tokens.normalise(value.to_s) || value.to_s
14
+ token[/heading_(\d)/, 1]&.to_i
15
+ end
16
+
17
+ def heading_number_candidates(scope, path:)
18
+ return buffer.index.nodes.select(&:heading?).to_h { |node| [ node.id, true ] } if output_scope?(scope)
19
+
20
+ resolver = SelectionResolver.new(index: buffer.index, options: options, diagnostics: diagnostics, path: path)
21
+ matches = scope["include"] ? resolver.resolve_with_includes(scope, scope["include"]) : resolver.resolve(scope)
22
+ matches.select { |unit| unit.is_a?(ComposerNode) && unit.heading? }.to_h { |node| [ node.id, true ] }
23
+ end
24
+
25
+ def heading_title_from_line(line)
26
+ line.to_s.sub(/\A\#{1,6}\s+/, "").strip.sub(/\A(?:\d+(?:\.[[:alnum:]]+)*\.?|[[:alpha:]]+\.)(?:\s+)/, "")
27
+ end
28
+
29
+ def heading_number(counters, level, formats)
30
+ (1..level).map do |counter_level|
31
+ next unless counters[counter_level].positive?
32
+
33
+ format_number(counters[counter_level], formats.fetch(counter_level, "decimal"))
34
+ end.compact.join(".")
35
+ end
36
+
37
+ def heading_number_title_separator(number)
38
+ number.include?(".") ? " " : ". "
39
+ end
40
+
41
+ def heading_number_formats(value)
42
+ default = Hash.new("decimal")
43
+ case value
44
+ when Hash
45
+ value.each_with_object(default.dup) do |(level, format), formats|
46
+ heading_level_value = heading_level(level)
47
+ formats[heading_level_value] = format.to_s if heading_level_value
48
+ end
49
+ when Array
50
+ value.each_with_index.with_object(default.dup) do |(format, index), formats|
51
+ formats[index + 1] = format.to_s
52
+ end
53
+ else
54
+ text = value.to_s.strip
55
+ return default if text.empty?
56
+
57
+ text.split(",").each_with_object(default.dup) do |part, formats|
58
+ level, format = part.split(":", 2).map(&:strip)
59
+ if format
60
+ heading_level_value = heading_level(level)
61
+ formats[heading_level_value] = format if heading_level_value
62
+ else
63
+ formats[formats.length + 1] = level
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def format_number(number, format)
70
+ case format.to_s
71
+ when "alpha", "letter" then alpha_number(number)
72
+ when "roman" then roman_number(number)
73
+ when "upper_alpha" then alpha_number(number).upcase
74
+ when "upper_roman" then roman_number(number).upcase
75
+ else number.to_s
76
+ end
77
+ end
78
+
79
+ def alpha_number(number)
80
+ value = number.to_i
81
+ result = +""
82
+ while value.positive?
83
+ value -= 1
84
+ result.prepend((97 + (value % 26)).chr)
85
+ value /= 26
86
+ end
87
+ result
88
+ end
89
+
90
+ def roman_number(number)
91
+ value = number.to_i
92
+ numerals = [
93
+ [ 1000, "m" ], [ 900, "cm" ], [ 500, "d" ], [ 400, "cd" ],
94
+ [ 100, "c" ], [ 90, "xc" ], [ 50, "l" ], [ 40, "xl" ],
95
+ [ 10, "x" ], [ 9, "ix" ], [ 5, "v" ], [ 4, "iv" ], [ 1, "i" ]
96
+ ]
97
+ numerals.each_with_object(+"") do |(amount, numeral), result|
98
+ while value >= amount
99
+ result << numeral
100
+ value -= amount
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end