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,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarkdownComposer
|
|
4
|
+
class Validator
|
|
5
|
+
attr_reader :plan, :sources, :options, :diagnostics
|
|
6
|
+
|
|
7
|
+
def initialize(plan, sources: [], options: {}, diagnostics: Diagnostics.new)
|
|
8
|
+
@plan = plan
|
|
9
|
+
@sources = Array(sources)
|
|
10
|
+
@options = options
|
|
11
|
+
@diagnostics = diagnostics
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
validate_output
|
|
16
|
+
validate_steps
|
|
17
|
+
validate_transforms
|
|
18
|
+
diagnostics.concat(plan.diagnostics)
|
|
19
|
+
diagnostics
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_output
|
|
25
|
+
diagnostics.error("output.invalid", "Output must be markdown or html", path: "output") unless %w[markdown html].include?(plan.output)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_steps
|
|
29
|
+
buffer_available = options.fetch(:initial_buffer, nil).to_s.strip != ""
|
|
30
|
+
previous_source = nil
|
|
31
|
+
plan.steps.each_with_index do |step, index|
|
|
32
|
+
path = "compose[#{index}]"
|
|
33
|
+
source = effective_source_ref(step["source"], previous_source)
|
|
34
|
+
validate_source(source, path: "#{path}.source", buffer_available: buffer_available, original_type: step.dig("source", "type"))
|
|
35
|
+
validate_selector(step["select"], :select, path: "#{path}.select")
|
|
36
|
+
Array(step["include"]).each_with_index { |item, item_index| validate_include(item, path: "#{path}.include[#{item_index}]", inside_data_block: data_block_selector?(step["select"])) }
|
|
37
|
+
validate_action(step.merge("source" => source, "_original_source_type" => step.dig("source", "type")), path: path)
|
|
38
|
+
validate_target(step["target"], path: "#{path}.target") if step["target"]
|
|
39
|
+
validate_row_transforms(step, path: path) if step["transforms"]
|
|
40
|
+
buffer_available ||= action_produces_buffer?(step)
|
|
41
|
+
previous_source = source
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def effective_source_ref(source, previous_source)
|
|
46
|
+
return source unless source&.fetch("type", nil) == "previous"
|
|
47
|
+
|
|
48
|
+
previous_source || { "type" => "current" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_source(source, path:, buffer_available: true, original_type: nil)
|
|
52
|
+
type = source&.fetch("type", nil)
|
|
53
|
+
diagnostics.error("source.unknown", "Unknown source type #{type.inspect}", path: path) unless Registries.default.sources[type]
|
|
54
|
+
case type
|
|
55
|
+
when "explicit"
|
|
56
|
+
diagnostics.error("source.identifier_missing", "explicit source requires key, id, or slug", path: path) unless source["key"] || source["id"] || source["slug"]
|
|
57
|
+
when "inherited"
|
|
58
|
+
diagnostics.error("source.inherited_key_missing", "inherited source requires from, key, id, or slug", path: path) unless source["from"] || source["key"] || source["id"] || source["slug"]
|
|
59
|
+
when "inline"
|
|
60
|
+
diagnostics.error("source.inline_empty", "inline source requires markdown or html content", path: path) if source["markdown"].to_s.empty? && source["html"].to_s.empty?
|
|
61
|
+
when "current"
|
|
62
|
+
current_sources = sources.select { |candidate| candidate.type == "current" }
|
|
63
|
+
diagnostics.error("source.current_ambiguous", "current source is ambiguous; provide a key", path: path) if current_sources.length > 1 && !source["key"]
|
|
64
|
+
diagnostics.error("source.previous_current_missing", "previous source has no previous row and no current source", path: path) if original_type == "previous" && current_sources.empty? && !source["key"]
|
|
65
|
+
when "buffer"
|
|
66
|
+
diagnostics.error("source.buffer_unavailable", "buffer source requires previous output", path: path) unless buffer_available
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_selector(selector, consumer, path:)
|
|
71
|
+
Array(selector["types"] || selector["type"]).each do |type|
|
|
72
|
+
validate_unit_token(type, consumer, path: path)
|
|
73
|
+
end
|
|
74
|
+
Take.validate(selector["take"] || {}).each do |message|
|
|
75
|
+
diagnostics.error("take.invalid", contextual_message("#{path}.take", take_message(message)), path: "#{path}.take")
|
|
76
|
+
end
|
|
77
|
+
Where.validate(selector["where"], diagnostics: diagnostics, path: "#{path}.where", options: options) if selector["where"]
|
|
78
|
+
Array(selector["include"]).each_with_index { |item, index| validate_include(item, path: "#{path}.include[#{index}]", inside_data_block: data_block_selector?(selector)) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_include(item, path:, inside_data_block: false)
|
|
82
|
+
if item["exclude"]
|
|
83
|
+
validate_include(item["exclude"], path: "#{path}.exclude", inside_data_block: inside_data_block)
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
if item["type"] == "data_path"
|
|
87
|
+
diagnostics.error("data_path.scope_invalid", "data_path is only valid inside data_block include scope", path: path) unless inside_data_block
|
|
88
|
+
diagnostics.error("data_path.path_missing", "data_path requires a path", path: "#{path}.path") if item["path"].to_s.strip.empty?
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
validate_selector(item, :include, path: path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_target(target, path:)
|
|
95
|
+
unless target
|
|
96
|
+
diagnostics.error("target.missing", "Target is missing", path: path)
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
return if %w[output start end in_place].include?(target["position"].to_s)
|
|
100
|
+
|
|
101
|
+
if target["placement"] == "between"
|
|
102
|
+
diagnostics.error("target.between_missing", "Between target requires start and end anchors", path: path) unless target["start"] && target["end"]
|
|
103
|
+
validate_target(target["start"], path: "#{path}.start")
|
|
104
|
+
validate_target(target["end"], path: "#{path}.end")
|
|
105
|
+
else
|
|
106
|
+
validate_selector(target, :target, path: path)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_action(step, path:)
|
|
111
|
+
action = step["action"]
|
|
112
|
+
entry = Registries.default.actions[action]
|
|
113
|
+
unless entry
|
|
114
|
+
diagnostics.error("action.unknown", "Unknown action #{action.inspect}", path: "#{path}.action")
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
target_rule = entry.support[:target_rule]
|
|
119
|
+
diagnostics.error("action.target_required", "#{action} requires a target", path: "#{path}.target") if target_rule == :required && !step["target"]
|
|
120
|
+
if %w[modify transform_buffer_target].include?(action) && Array(step["transforms"]).empty?
|
|
121
|
+
diagnostics.error("action.transform_definition_missing", "#{action} requires transforms", path: "#{path}.transforms")
|
|
122
|
+
end
|
|
123
|
+
diagnostics.error("action.move_source_invalid", "move should use source type buffer", path: "#{path}.source") if action == "move" && step.dig("source", "type") != "buffer"
|
|
124
|
+
return unless step["target"]
|
|
125
|
+
|
|
126
|
+
placement = step.dig("target", "placement")
|
|
127
|
+
diagnostics.error("target.placement_conflict", "insert_before target placement must be before", path: "#{path}.target") if action == "insert_before" && placement && placement != "before"
|
|
128
|
+
diagnostics.error("target.placement_conflict", "insert_after target placement must be after", path: "#{path}.target") if action == "insert_after" && placement && placement != "after"
|
|
129
|
+
diagnostics.error("target.between_required", "insert_between requires a Between target", path: "#{path}.target") if action == "insert_between" && placement != "between"
|
|
130
|
+
diagnostics.error("target.selector_required", "#{action} requires selector target, not start/end", path: "#{path}.target") if %w[remove_buffer_target transform_buffer_target].include?(action) && step.dig("target", "position")
|
|
131
|
+
validate_in_place_target(step, path: path) if step.dig("target", "position") == "in_place"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_in_place_target(step, path:)
|
|
135
|
+
action = step["action"]
|
|
136
|
+
diagnostics.error(
|
|
137
|
+
"target.in_place_action_invalid",
|
|
138
|
+
"target in_place is only valid with action modify",
|
|
139
|
+
path: "#{path}.target"
|
|
140
|
+
) unless action == "modify"
|
|
141
|
+
|
|
142
|
+
return if step["_original_source_type"] == "buffer"
|
|
143
|
+
|
|
144
|
+
diagnostics.error(
|
|
145
|
+
"target.in_place_source_invalid",
|
|
146
|
+
"target in_place requires source type buffer",
|
|
147
|
+
path: "#{path}.source"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_row_transforms(step, path:)
|
|
152
|
+
Array(step["transforms"]).each_with_index do |transform, index|
|
|
153
|
+
if step["action"] == "transform_buffer_target" && transform["_scope_missing"]
|
|
154
|
+
next unless step["target"]
|
|
155
|
+
|
|
156
|
+
transform = transform.merge("scope" => step["target"])
|
|
157
|
+
end
|
|
158
|
+
validate_transform(transform, path: "#{path}.transforms[#{index}]")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_transforms
|
|
163
|
+
plan.transforms.each_with_index do |transform, index|
|
|
164
|
+
validate_transform(transform, path: "transform[#{index}]")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_transform(transform, path:)
|
|
169
|
+
validate_selector(transform["scope"], :scope, path: "#{path}.scope")
|
|
170
|
+
entry = Registries.default.transforms[transform["transform"]]
|
|
171
|
+
unless entry
|
|
172
|
+
diagnostics.error("transform.unknown", "Unknown transform #{transform["transform"].inspect}", path: path)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
support = entry.support[:transform]
|
|
177
|
+
diagnostics.error("transform.policy_disabled", "#{transform["transform"]} requires adapter policy", path: path) if support == :adapter_policy && !options.fetch(:adapter_transforms, false)
|
|
178
|
+
modes = entry.support[:modes]
|
|
179
|
+
diagnostics.error("transform.mode_invalid", "Invalid mode #{transform["mode"].inspect} for #{entry.token}", path: "#{path}.mode") if modes.any? && !modes.include?(transform["mode"].to_s)
|
|
180
|
+
validate_transform_options(transform, path: path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_transform_options(transform, path:)
|
|
184
|
+
options_hash = transform["options"] || {}
|
|
185
|
+
return if plan.diagnostics.errors.any? { |diagnostic| diagnostic.code == "transform.options_syntax" && diagnostic.path == "#{path}.options" }
|
|
186
|
+
|
|
187
|
+
allowed = allowed_transform_options(transform)
|
|
188
|
+
options_hash.each_key do |key|
|
|
189
|
+
diagnostics.error("transform.option_unknown", "Unknown option #{key} for #{transform["transform"]}", path: "#{path}.options.#{key}") unless allowed.include?(key)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
required = TransformOptions.required(transform["transform"], transform["mode"])
|
|
193
|
+
|
|
194
|
+
required.each do |key|
|
|
195
|
+
diagnostics.error("transform.option_missing", "Missing required option #{key}", path: "#{path}.options.#{key}") unless options_hash.key?(key)
|
|
196
|
+
end
|
|
197
|
+
if options_hash["content"].to_s.match?(/\{\{|\}\}/)
|
|
198
|
+
diagnostics.error("transform.option_invalid", "content uses template braces; use Composer scoped fields such as field[text]", path: "#{path}.options.content")
|
|
199
|
+
end
|
|
200
|
+
validate_content_fields(options_hash["content"], path: "#{path}.options.content") if options_hash.key?("content")
|
|
201
|
+
if options_hash.key?("parse_as") && !%w[markdown text html].include?(options_hash["parse_as"].to_s)
|
|
202
|
+
diagnostics.error("transform.option_invalid", "parse_as must be markdown, text, or html", path: "#{path}.options.parse_as")
|
|
203
|
+
end
|
|
204
|
+
if options_hash.key?("case_sensitive") && !boolean?(options_hash["case_sensitive"])
|
|
205
|
+
diagnostics.error("transform.option_invalid", "case_sensitive must be true or false", path: "#{path}.options.case_sensitive")
|
|
206
|
+
end
|
|
207
|
+
if options_hash.key?("skip_if_present") && !boolean?(options_hash["skip_if_present"])
|
|
208
|
+
diagnostics.error("transform.option_invalid", "skip_if_present must be true or false", path: "#{path}.options.skip_if_present")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
keys_requiring_unit = %w[as unit]
|
|
212
|
+
keys_requiring_unit << "to" if transform["transform"] == "heading_levels"
|
|
213
|
+
keys_requiring_unit.each do |key|
|
|
214
|
+
next unless options_hash[key].is_a?(String)
|
|
215
|
+
next if Registries.default.unit_tokens[options_hash[key]]
|
|
216
|
+
|
|
217
|
+
diagnostics.error("transform.option_invalid", "#{key} is not a valid unit token", path: "#{path}.options.#{key}")
|
|
218
|
+
end
|
|
219
|
+
%w[by limit start].each do |key|
|
|
220
|
+
next unless options_hash.key?(key)
|
|
221
|
+
|
|
222
|
+
diagnostics.error("transform.option_invalid", "#{key} must be a positive integer", path: "#{path}.options.#{key}") if options_hash[key].to_i <= 0
|
|
223
|
+
end
|
|
224
|
+
diagnostics.error("transform.output_invalid", "sanitise requires HTML output", path: path) if transform["transform"] == "sanitise" && plan.output != "html"
|
|
225
|
+
validate_target(options_hash["target"], path: "#{path}.options.target") if options_hash["target"].is_a?(Hash)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def allowed_transform_options(transform)
|
|
229
|
+
TransformOptions.allowed(transform["transform"], transform["mode"], adapter_option_keys: options[:adapter_option_keys])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def boolean?(value)
|
|
233
|
+
value == true || value == false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def validate_content_fields(content, path:)
|
|
237
|
+
content.to_s.scan(/field\[([a-z_][a-z0-9_]*(?::[a-z0-9_]+)?(?:\.[a-z_][a-z0-9_]*(?::[a-z0-9_]+)?)*(?:\[[^\]]+\])?)\]/).flatten.each do |reference|
|
|
238
|
+
next if valid_content_field_reference?(reference)
|
|
239
|
+
|
|
240
|
+
diagnostics.error("transform.option_invalid", "Unknown scoped content field field[#{reference}]", path: path)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def valid_content_field_reference?(reference)
|
|
245
|
+
reference = reference.to_s.sub(/\[[^\]]+\]\z/, "")
|
|
246
|
+
parts = reference.to_s.split(".")
|
|
247
|
+
return valid_current_field?(parts.first) if parts.length == 1
|
|
248
|
+
|
|
249
|
+
case parts.first
|
|
250
|
+
when "current"
|
|
251
|
+
parts.length == 2 && valid_current_field?(parts.last)
|
|
252
|
+
when "section"
|
|
253
|
+
valid_section_reference?(parts)
|
|
254
|
+
when "parent_section"
|
|
255
|
+
parts.length == 2 && valid_section_field?(parts.last)
|
|
256
|
+
else
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def valid_current_field?(field)
|
|
262
|
+
%w[
|
|
263
|
+
text source_text type index start_line end_line title level number
|
|
264
|
+
href src alt language diagram_type format location item_count ordered
|
|
265
|
+
item_index row_count column_count row_index column_index section
|
|
266
|
+
path key value
|
|
267
|
+
].include?(field.to_s)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def valid_section_field?(field)
|
|
271
|
+
%w[title level text source_text type index start_line end_line number].include?(field.to_s)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def valid_section_reference?(parts)
|
|
275
|
+
return parts.length == 2 && valid_section_field?(parts.last) if parts[1] && valid_section_field?(parts[1])
|
|
276
|
+
return true if parts.length == 3 && parts[1] == "parent" && valid_section_field?(parts.last)
|
|
277
|
+
return true if parts.length == 4 && parts[1] == "parent" && parts[2] == "parent" && valid_section_field?(parts.last)
|
|
278
|
+
return true if parts.length == 3 && valid_ancestor_reference?(parts[1]) && valid_section_field?(parts.last)
|
|
279
|
+
|
|
280
|
+
false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def valid_ancestor_reference?(part)
|
|
284
|
+
return false unless part.to_s.start_with?("ancestor:")
|
|
285
|
+
|
|
286
|
+
heading = part.split(":", 2).last
|
|
287
|
+
token = Registries.default.unit_tokens.normalise(heading) || heading.to_s
|
|
288
|
+
token.match?(/\Aheading_[1-6]\z/)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate_unit_token(type, consumer, path:)
|
|
292
|
+
entry = Registries.default.unit_tokens[type]
|
|
293
|
+
unless entry
|
|
294
|
+
diagnostics.error("token.unknown", contextual_message(path, unknown_token_message(type)), path: path)
|
|
295
|
+
return
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
diagnostics.error("token.unsupported", contextual_message(path, "#{type} is not supported for #{consumer}"), path: path) unless entry.supports?(consumer, options)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def data_block_selector?(selector)
|
|
302
|
+
Array(selector["types"] || selector["type"]).map(&:to_s).include?("data_block")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def action_produces_buffer?(step)
|
|
306
|
+
step["action"] != "remove_buffer_target"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def contextual_message(path, message)
|
|
310
|
+
"#{human_path(path)}: #{message}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def human_path(path)
|
|
314
|
+
row_index = path[/compose\[(\d+)\]/, 1]
|
|
315
|
+
return path unless row_index
|
|
316
|
+
|
|
317
|
+
field = path.sub(/\Acompose\[\d+\]\.?/, "")
|
|
318
|
+
field = "row" if field.empty?
|
|
319
|
+
"Row #{row_index.to_i + 1} #{field}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def unknown_token_message(type)
|
|
323
|
+
value = type.to_s
|
|
324
|
+
if value.match?(/\b(wher|whre|wheree|were)\b/i)
|
|
325
|
+
return "Unknown unit token #{value.inspect}. Possible error: the `where` keyword appears misspelled. Use `<unit>[take] where <condition>`."
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
suggestion = closest_unit_token(value)
|
|
329
|
+
if suggestion
|
|
330
|
+
"Unknown unit token #{value.inspect}. Did you mean #{suggestion.inspect}?"
|
|
331
|
+
else
|
|
332
|
+
"Unknown unit token #{value.inspect}. Check the token spelling against the unit token registry."
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def take_message(message)
|
|
337
|
+
if message =~ /\AUnknown take operator "([^"]+)"\z/
|
|
338
|
+
operator = Regexp.last_match(1)
|
|
339
|
+
suggestion = closest_take_operator(operator)
|
|
340
|
+
return suggestion ? "#{message}. Did you mean #{suggestion.inspect}?" : "#{message}. Check the take modifier spelling."
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
message
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def closest_unit_token(value)
|
|
347
|
+
candidates = Registries.default.unit_tokens.tokens
|
|
348
|
+
candidate = candidates.min_by { |item| edit_distance(value, item) }
|
|
349
|
+
candidate if candidate && edit_distance(value, candidate) <= 3
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def closest_take_operator(value)
|
|
353
|
+
candidates = Registries.default.take.keys.map(&:to_s)
|
|
354
|
+
candidate = candidates.min_by { |item| edit_distance(value, item) }
|
|
355
|
+
candidate if candidate && edit_distance(value, candidate) <= 3
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def edit_distance(left, right)
|
|
359
|
+
left = left.to_s
|
|
360
|
+
right = right.to_s
|
|
361
|
+
costs = (0..right.length).to_a
|
|
362
|
+
left.each_char.with_index(1) do |left_char, left_index|
|
|
363
|
+
previous = costs[0]
|
|
364
|
+
costs[0] = left_index
|
|
365
|
+
right.each_char.with_index(1) do |right_char, right_index|
|
|
366
|
+
current = costs[right_index]
|
|
367
|
+
costs[right_index] = if left_char == right_char
|
|
368
|
+
previous
|
|
369
|
+
else
|
|
370
|
+
[ costs[right_index] + 1, costs[right_index - 1] + 1, previous + 1 ].min
|
|
371
|
+
end
|
|
372
|
+
previous = current
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
costs[right.length]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarkdownComposer
|
|
4
|
+
def self.value_object(*members, &block)
|
|
5
|
+
Struct.new(*members, keyword_init: true) do
|
|
6
|
+
def with(attributes)
|
|
7
|
+
values = {}
|
|
8
|
+
each_pair { |name, value| values[name] = value }
|
|
9
|
+
self.class.new(**values.merge(attributes))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_eval(&block) if block
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|