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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +278 -0
- data/ROADMAP.md +80 -0
- data/docs/_md_composer_architecture.md +50 -0
- data/docs/_md_composer_cheatsheet.md +72 -0
- data/docs/_md_composer_concepts.md +64 -0
- data/docs/_md_composer_dev_guide.md +55 -0
- data/docs/_md_composer_getting_started.md +114 -0
- data/docs/_md_composer_readme.md +93 -0
- data/docs/_md_composer_user_guide.md +65 -0
- data/docs/ai/md_composer_ai_audit.md +35 -0
- data/docs/ai/md_composer_ai_canonical_docs.md +44 -0
- data/docs/ai/md_composer_ai_source_map.md +39 -0
- data/docs/compose/md_composer_compose_actions.md +338 -0
- data/docs/compose/md_composer_compose_anatomy.md +156 -0
- data/docs/compose/md_composer_compose_buffer.md +81 -0
- data/docs/compose/md_composer_compose_examples.md +31 -0
- data/docs/compose/md_composer_compose_include.md +136 -0
- data/docs/compose/md_composer_compose_select.md +198 -0
- data/docs/compose/md_composer_compose_sources.md +161 -0
- data/docs/compose/md_composer_compose_targets.md +194 -0
- data/docs/examples/md_composer_example_basic_compose.md +57 -0
- data/docs/examples/md_composer_example_buffer_target_actions.md +83 -0
- data/docs/examples/md_composer_example_fixtures.md +62 -0
- data/docs/examples/md_composer_example_html_output.md +50 -0
- data/docs/examples/md_composer_example_modify.md +77 -0
- data/docs/examples/md_composer_example_multi_row_compose.md +67 -0
- data/docs/examples/md_composer_example_ruby_plans.md +62 -0
- data/docs/examples/md_composer_example_structured_data.md +68 -0
- data/docs/examples/md_composer_example_transforms.md +68 -0
- data/docs/examples/md_composer_example_yaml_json_rows.md +56 -0
- data/docs/examples/md_composer_examples_readme.md +45 -0
- data/docs/examples/md_composer_runnable_examples.md +374 -0
- data/docs/examples/md_composer_source_ruby_dsl.md +88 -0
- data/docs/reference/md_composer_nested.md +170 -0
- data/docs/reference/md_composer_reference_api.md +71 -0
- data/docs/reference/md_composer_reference_capabilities.md +63 -0
- data/docs/reference/md_composer_reference_diagnostics.md +54 -0
- data/docs/reference/md_composer_reference_plan_schema.md +75 -0
- data/docs/reference/md_composer_reference_registries.md +63 -0
- data/docs/reference/md_composer_take.md +221 -0
- data/docs/reference/md_composer_unit_tokens.md +228 -0
- data/docs/reference/md_composer_where.md +227 -0
- data/docs/transform/md_composer_transform_anatomy.md +112 -0
- data/docs/transform/md_composer_transform_examples.md +30 -0
- data/docs/transform/md_composer_transform_modes.md +83 -0
- data/docs/transform/md_composer_transform_options.md +142 -0
- data/docs/transform/md_composer_transform_scope.md +97 -0
- data/docs/transform/md_composer_transform_transforms.md +99 -0
- data/examples/README.md +20 -0
- data/examples/advanced_composer.rb +207 -0
- data/examples/basic_compose.rb +24 -0
- data/examples/complex_composer.rb +235 -0
- data/examples/example_support.rb +18 -0
- data/examples/fixtures/current.md +179 -0
- data/examples/fixtures/faq.md +58 -0
- data/examples/fixtures/guide.md +62 -0
- data/examples/fixtures/site_intro.md +29 -0
- data/examples/fixtures/source.html +22 -0
- data/examples/html_input.rb +26 -0
- data/examples/output/advanced_composer.md +76 -0
- data/examples/output/basic_compose.md +25 -0
- data/examples/output/complex_composer.md +85 -0
- data/examples/output/html_input.md +4 -0
- data/examples/output/source_list_dsl.md +126 -0
- data/examples/output/standard_composer.md +46 -0
- data/examples/output/standard_sources_buffer.md +31 -0
- data/examples/output/yaml_plan.md +43 -0
- data/examples/plans/basic.yml +20 -0
- data/examples/source_list_dsl.rb +41 -0
- data/examples/standard_composer.rb +42 -0
- data/examples/standard_sources_buffer.rb +62 -0
- data/examples/yaml_plan.rb +17 -0
- data/lib/markdown_composer/capabilities.rb +223 -0
- data/lib/markdown_composer/composition_buffer.rb +378 -0
- data/lib/markdown_composer/data_path.rb +313 -0
- data/lib/markdown_composer/diagnostics.rb +63 -0
- data/lib/markdown_composer/document_index/html_parser.rb +84 -0
- data/lib/markdown_composer/document_index/markdown_parser.rb +338 -0
- data/lib/markdown_composer/document_index.rb +94 -0
- data/lib/markdown_composer/executor.rb +284 -0
- data/lib/markdown_composer/markdown_renderer.rb +105 -0
- data/lib/markdown_composer/plan.rb +436 -0
- data/lib/markdown_composer/plan_builder.rb +111 -0
- data/lib/markdown_composer/registries/action_entries.rb +26 -0
- data/lib/markdown_composer/registries/condition_entries.rb +58 -0
- data/lib/markdown_composer/registries/registry.rb +69 -0
- data/lib/markdown_composer/registries/source_entries.rb +18 -0
- data/lib/markdown_composer/registries/support_values.rb +23 -0
- data/lib/markdown_composer/registries/take_entries.rb +31 -0
- data/lib/markdown_composer/registries/take_registry.rb +18 -0
- data/lib/markdown_composer/registries/target_entries.rb +40 -0
- data/lib/markdown_composer/registries/unit_token_entries.rb +62 -0
- data/lib/markdown_composer/registries/where_registry.rb +84 -0
- data/lib/markdown_composer/registries.rb +46 -0
- data/lib/markdown_composer/result.rb +34 -0
- data/lib/markdown_composer/selection_resolver.rb +181 -0
- data/lib/markdown_composer/source.rb +57 -0
- data/lib/markdown_composer/source_list_builder.rb +47 -0
- data/lib/markdown_composer/take.rb +129 -0
- data/lib/markdown_composer/transform_options.rb +66 -0
- data/lib/markdown_composer/transform_runner/content_placement.rb +63 -0
- data/lib/markdown_composer/transform_runner/field_interpolator.rb +213 -0
- data/lib/markdown_composer/transform_runner/heading_numbering.rb +106 -0
- data/lib/markdown_composer/transform_runner/scope_resolver.rb +87 -0
- data/lib/markdown_composer/transform_runner.rb +264 -0
- data/lib/markdown_composer/transforms/default_entries.rb +31 -0
- data/lib/markdown_composer/transforms/registry.rb +11 -0
- data/lib/markdown_composer/validator.rb +378 -0
- data/lib/markdown_composer/value_object.rb +15 -0
- data/lib/markdown_composer/version.rb +5 -0
- data/lib/markdown_composer/where.rb +313 -0
- data/lib/markdown_composer.rb +114 -0
- 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
|