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,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def escape_attribute(text)
|
|
96
|
+
escape(text.to_s).gsub('"', """)
|
|
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
|