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,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ class Executor
5
+ attr_reader :sources, :plan, :options, :diagnostics, :stages
6
+
7
+ def initialize(sources:, plan:, options: {})
8
+ @sources = Array(sources).map { |source| Source.build(source) }
9
+ @plan = plan
10
+ @options = options
11
+ @diagnostics = Diagnostics.new
12
+ @stages = {}
13
+ end
14
+
15
+ def call
16
+ Validator.new(plan, sources: sources, options: options, diagnostics: diagnostics).call
17
+ return result(CompositionBuffer.new(diagnostics: diagnostics), html: nil) if diagnostics.any_errors?
18
+
19
+ buffer = CompositionBuffer.new(initial_buffer, diagnostics: diagnostics)
20
+ return result(buffer, html: nil) if diagnostics.any_errors?
21
+
22
+ indexes = {}
23
+ previous_source = nil
24
+
25
+ plan.steps.each_with_index do |step, index|
26
+ row_path = "compose[#{index}]"
27
+ source_ref = effective_source_ref(step["source"], previous_source)
28
+ source_index = source_index_for(source_ref, buffer, indexes, path: "#{row_path}.source")
29
+ next unless source_index
30
+
31
+ resolver = SelectionResolver.new(index: source_index, options: options, diagnostics: diagnostics, path: "#{row_path}.select")
32
+ units = if step["action"] == "remove_buffer_target"
33
+ []
34
+ else
35
+ resolver.resolve_with_includes(step["select"], step["include"])
36
+ end
37
+ diagnostics.warn("selection.empty", "Step selected no content", path: row_path) if units.empty? && step["action"] != "remove_buffer_target"
38
+ apply_action(buffer, step, units, row_path: row_path)
39
+ stages["step_#{index + 1}"] = buffer.markdown if options.fetch(:stages, false)
40
+ previous_source = source_ref
41
+ end
42
+
43
+ stages["composed"] = buffer.markdown if options.fetch(:stages, false)
44
+ TransformRunner.new(buffer: buffer, transforms: plan.transforms, output: plan.output, options: options, diagnostics: diagnostics, stages: stages).call
45
+ html = if plan.output == "html"
46
+ rendered = MarkdownRenderer.to_html(buffer.markdown, diagnostics: diagnostics)
47
+ MarkdownRenderer.apply_link_modes(rendered, html_link_modes, diagnostics: diagnostics)
48
+ end
49
+ result(buffer, html: html)
50
+ end
51
+
52
+ private
53
+
54
+ def initial_buffer
55
+ return options.fetch(:initial_buffer) if options.key?(:initial_buffer)
56
+ return +"" unless plan.steps.empty?
57
+
58
+ current_sources = sources.select { |source| source.type == "current" }
59
+ return +"" if current_sources.empty?
60
+ if current_sources.length > 1
61
+ diagnostics.error("source.current_ambiguous", "current source is ambiguous; provide a key", path: "source")
62
+ return +""
63
+ end
64
+
65
+ current_sources.first.content
66
+ end
67
+
68
+ def effective_source_ref(source_ref, previous_source)
69
+ return source_ref unless source_ref["type"] == "previous"
70
+
71
+ previous_source || { "type" => "current" }
72
+ end
73
+
74
+ def source_index_for(source_ref, buffer, indexes, path:)
75
+ if source_ref["type"] == "buffer"
76
+ return buffer.index
77
+ end
78
+
79
+ if source_ref["type"] == "inline"
80
+ source = Source.new(
81
+ key: source_ref["key"] || "inline",
82
+ type: "inline",
83
+ markdown: source_ref["markdown"],
84
+ html: source_ref["html"],
85
+ preferred_format: source_ref["preferred_format"] || :markdown,
86
+ metadata: source_ref["metadata"] || {}
87
+ )
88
+ return DocumentIndex.build(source, diagnostics: diagnostics)
89
+ end
90
+
91
+ source = resolve_source(source_ref)
92
+ unless source
93
+ diagnostics.error("source.missing", "Missing source for #{source_ref.inspect}", path: path)
94
+ return nil
95
+ end
96
+ indexes[source.key] ||= DocumentIndex.build(source, diagnostics: diagnostics)
97
+ end
98
+
99
+ def resolve_source(source_ref)
100
+ type = source_ref["type"]
101
+ key = source_ref["key"] || source_ref["id"] || source_ref["slug"]
102
+ return sources.find { |source| source.type == type } if type == "current" && !key
103
+ return sources.find { |source| source.key == key.to_s } if key
104
+
105
+ sources.find { |source| source.type == type } || sources.find { |source| source.key == type }
106
+ end
107
+
108
+ def apply_action(buffer, step, units, row_path:)
109
+ target = step["target"] || default_target(step["action"])
110
+ case step["action"]
111
+ when "set" then buffer.set(units)
112
+ when "append" then buffer.append(units)
113
+ when "prepend" then buffer.prepend(units)
114
+ when "insert_before" then buffer.insert_before(target, units, options: options)
115
+ when "insert_after" then buffer.insert_after(target, units, options: options)
116
+ when "insert_between" then buffer.insert_between(target["start"], target["end"], units, options: options)
117
+ when "replace" then buffer.replace(target, units, options: options)
118
+ when "copy" then buffer.copy(target, units, options: options)
119
+ when "move" then buffer.move(step["select"], target, units, options: options)
120
+ when "modify" then modify_source_fragment(buffer, step, units, target, row_path: row_path)
121
+ when "remove_buffer_target" then buffer.remove(target, options: options)
122
+ when "transform_buffer_target"
123
+ transforms = Array(step["transforms"])
124
+ transforms = transforms.map { |transform| transform["_scope_missing"] ? transform.merge("scope" => target) : transform }
125
+ TransformRunner.new(
126
+ buffer: buffer,
127
+ transforms: transforms,
128
+ output: plan.output,
129
+ options: options,
130
+ diagnostics: diagnostics,
131
+ stages: stages,
132
+ path_prefix: "#{row_path}.transforms"
133
+ ).call
134
+ end
135
+ end
136
+
137
+ def modify_source_fragment(buffer, step, units, target, row_path:)
138
+ return modify_source_fragment_in_place(buffer, step, units, row_path: row_path) if target&.dig("position") == "in_place"
139
+
140
+ fragment = CompositionBuffer.new(diagnostics: diagnostics)
141
+ fragment.set(units)
142
+ TransformRunner.new(
143
+ buffer: fragment,
144
+ transforms: Array(step["transforms"]),
145
+ output: plan.output,
146
+ options: options,
147
+ diagnostics: diagnostics,
148
+ stages: stages,
149
+ path_prefix: "#{row_path}.transforms"
150
+ ).call
151
+ buffer.place_markdown(target, fragment.markdown, origin_nodes: fragment.origin_nodes, options: options)
152
+ end
153
+
154
+ def modify_source_fragment_in_place(buffer, step, units, row_path:)
155
+ if buffer.empty?
156
+ diagnostics.error("target.in_place_buffer_empty", "target in_place requires existing buffer content", path: "#{row_path}.target")
157
+ return
158
+ end
159
+ if units.empty?
160
+ diagnostics.error("target.in_place_selection_empty", "target in_place matched no buffer content", path: row_path)
161
+ return
162
+ end
163
+
164
+ ranges = in_place_replacement_ranges(buffer, step, units)
165
+ if ranges.empty?
166
+ diagnostics.error("target.in_place_selection_empty", "target in_place matched no replaceable buffer content", path: row_path)
167
+ return
168
+ end
169
+
170
+ replacements = in_place_replacements(buffer, step, ranges, row_path: row_path)
171
+ return if diagnostics.any_errors?
172
+
173
+ buffer.replace_markdown_ranges(replacements)
174
+ end
175
+
176
+ def in_place_replacement_ranges(buffer, step, units)
177
+ if include_all?(step["include"])
178
+ resolver = SelectionResolver.new(index: buffer.index, options: options, diagnostics: diagnostics, path: "select")
179
+ return line_ranges(resolver.resolve(step["select"]))
180
+ end
181
+
182
+ line_ranges(units)
183
+ end
184
+
185
+ def in_place_replacements(buffer, step, ranges, row_path:)
186
+ fragment = CompositionBuffer.new(marked_in_place_fragment(buffer, ranges), diagnostics: diagnostics)
187
+ TransformRunner.new(
188
+ buffer: fragment,
189
+ transforms: Array(step["transforms"]),
190
+ output: plan.output,
191
+ options: options,
192
+ diagnostics: diagnostics,
193
+ stages: stages,
194
+ path_prefix: "#{row_path}.transforms"
195
+ ).call
196
+
197
+ ranges.map.with_index do |range, index|
198
+ { range: range, markdown: in_place_fragment_content(fragment.markdown, index, row_path: row_path) }
199
+ end
200
+ end
201
+
202
+ def marked_in_place_fragment(buffer, ranges)
203
+ idx = buffer.index
204
+ ranges.each_with_index.map do |range, index|
205
+ [
206
+ in_place_marker(index, "start"),
207
+ idx.markdown_for_range(range.begin, range.end),
208
+ in_place_marker(index, "end")
209
+ ].join
210
+ end.join("\n")
211
+ end
212
+
213
+ def in_place_fragment_content(markdown, index, row_path:)
214
+ pattern = /
215
+ <!--\ markdown-composer-in-place-start-#{index}\ -->\n?
216
+ (?<content>.*?)
217
+ \n?<!--\ markdown-composer-in-place-end-#{index}\ -->
218
+ /mx
219
+ match = markdown.match(pattern)
220
+ unless match
221
+ diagnostics.error("target.in_place_marker_missing", "target in_place could not map transformed content back to buffer range", path: row_path)
222
+ return ""
223
+ end
224
+
225
+ trim_outer_blank_lines(match[:content])
226
+ end
227
+
228
+ def in_place_marker(index, position)
229
+ "<!-- markdown-composer-in-place-#{position}-#{index} -->\n"
230
+ end
231
+
232
+ def include_all?(include_config)
233
+ Array(include_config).all? { |item| item["type"] == "all" && item.keys == [ "type" ] }
234
+ end
235
+
236
+ def line_ranges(units)
237
+ Array(units).compact
238
+ .select { |unit| unit.respond_to?(:start_line) && unit.respond_to?(:end_line) }
239
+ .map { |unit| unit.start_line..unit.end_line }
240
+ .sort_by(&:begin)
241
+ .each_with_object([]) do |range, merged|
242
+ if merged.any? && range.begin <= merged.last.end + 1
243
+ merged[-1] = merged.last.begin..[ merged.last.end, range.end ].max
244
+ else
245
+ merged << range
246
+ end
247
+ end
248
+ end
249
+
250
+ def trim_outer_blank_lines(content)
251
+ lines = content.to_s.lines
252
+ lines.shift while lines.first&.strip&.empty?
253
+ lines.pop while lines.last&.strip&.empty?
254
+ lines.join
255
+ end
256
+
257
+ def default_target(action)
258
+ case action
259
+ when "set" then { "position" => "output" }
260
+ when "append" then { "position" => "end" }
261
+ when "prepend" then { "position" => "start" }
262
+ when "modify" then { "position" => "end" }
263
+ end
264
+ end
265
+
266
+ def result(buffer, html:)
267
+ errors = diagnostics.errors
268
+ Result.new(
269
+ output: plan.output,
270
+ buffer: buffer,
271
+ markdown: buffer.markdown,
272
+ html: html,
273
+ diagnostics: diagnostics.to_a - errors,
274
+ errors: errors,
275
+ stages: stages
276
+ )
277
+ end
278
+
279
+ def html_link_modes
280
+ plan.transforms.select { |transform| transform["transform"] == "links" && %w[nofollow target_blank].include?(transform["mode"].to_s) }
281
+ .map { |transform| transform["mode"].to_s }
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MarkdownComposer
4
+ module MarkdownRenderer
5
+ module_function
6
+
7
+ def to_html(markdown, diagnostics: Diagnostics.new)
8
+ begin
9
+ require "commonmarker"
10
+ return render_with_commonmarker(markdown)
11
+ rescue LoadError, StandardError
12
+ diagnostics.warn("output.html_fallback", "CommonMarker HTML rendering unavailable; using simple fallback", path: "output")
13
+ end
14
+
15
+ fallback_html(markdown)
16
+ end
17
+
18
+ def apply_link_modes(html, modes, diagnostics: Diagnostics.new)
19
+ modes = Array(modes).map(&:to_s)
20
+ return html if html.to_s.empty? || modes.empty?
21
+
22
+ require "nokogiri"
23
+ fragment = Nokogiri::HTML5.fragment(html)
24
+ fragment.css("a[href]").each do |link|
25
+ href = link["href"].to_s
26
+ next if href.empty? || href.start_with?("#")
27
+
28
+ merge_rel(link, "nofollow") if modes.include?("nofollow")
29
+ if modes.include?("target_blank")
30
+ link["target"] = "_blank"
31
+ merge_rel(link, "noopener")
32
+ merge_rel(link, "noreferrer")
33
+ end
34
+ end
35
+ fragment.to_html
36
+ rescue LoadError, StandardError => e
37
+ diagnostics.warn("transform.html_link_postprocess_failed", "HTML link transform skipped: #{e.message}", path: "transform")
38
+ html
39
+ end
40
+
41
+ def render_with_commonmarker(markdown)
42
+ return CommonMarker.render_html(markdown) if defined?(CommonMarker) && CommonMarker.respond_to?(:render_html)
43
+ return Commonmarker.to_html(markdown) if defined?(Commonmarker) && Commonmarker.respond_to?(:to_html)
44
+ return CommonMarker.render_doc(markdown).to_html if defined?(CommonMarker) && CommonMarker.respond_to?(:render_doc)
45
+
46
+ raise "unsupported CommonMarker API"
47
+ end
48
+
49
+ def fallback_html(markdown)
50
+ html = []
51
+ list_items = []
52
+ markdown.lines.each do |line|
53
+ case line
54
+ when /\A\s*[-*+]\s+(.+)/
55
+ list_items << "<li>#{render_inline(Regexp.last_match(1).strip)}</li>"
56
+ when /\A(\#{1,6})\s+(.+)/
57
+ flush_list(html, list_items)
58
+ level = Regexp.last_match(1).length
59
+ html << "<h#{level}>#{render_inline(Regexp.last_match(2).strip)}</h#{level}>"
60
+ when /\A\s*$/
61
+ flush_list(html, list_items)
62
+ else
63
+ flush_list(html, list_items)
64
+ html << "<p>#{render_inline(line.strip)}</p>"
65
+ end
66
+ end
67
+ flush_list(html, list_items)
68
+ html.join("\n")
69
+ end
70
+
71
+ def render_inline(text)
72
+ rendered = +""
73
+ last_index = 0
74
+ text.to_s.to_enum(:scan, /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/).each do
75
+ match = Regexp.last_match
76
+ rendered << escape(text.to_s[last_index...match.begin(0)].to_s)
77
+ rendered << "<a href=\"#{escape_attribute(match[2])}\">#{escape(match[1])}</a>"
78
+ last_index = match.end(0)
79
+ end
80
+ rendered << escape(text.to_s[last_index..].to_s)
81
+ rendered
82
+ end
83
+
84
+ def flush_list(html, list_items)
85
+ return if list_items.empty?
86
+
87
+ html << "<ul>#{list_items.join}</ul>"
88
+ list_items.clear
89
+ end
90
+
91
+ def escape(text)
92
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
93
+ end
94
+
95
+ def escape_attribute(text)
96
+ escape(text.to_s).gsub('"', "&quot;")
97
+ end
98
+
99
+ def merge_rel(link, value)
100
+ rel_values = link["rel"].to_s.split(/\s+/).reject(&:empty?)
101
+ rel_values << value unless rel_values.include?(value)
102
+ link["rel"] = rel_values.join(" ")
103
+ end
104
+ end
105
+ end