slamdown 0.5.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 +7 -0
- data/lib/slamdown/conversion/configuration.rb +35 -0
- data/lib/slamdown/conversion/configuration_validator.rb +235 -0
- data/lib/slamdown/conversion/converter.rb +98 -0
- data/lib/slamdown/conversion/normalisation.rb +21 -0
- data/lib/slamdown/conversion/normalised_node.rb +40 -0
- data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
- data/lib/slamdown/conversion/normaliser/content.rb +174 -0
- data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
- data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
- data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
- data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
- data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
- data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
- data/lib/slamdown/conversion/normaliser.rb +58 -0
- data/lib/slamdown/conversion/policy.rb +178 -0
- data/lib/slamdown/conversion/prepared_node.rb +23 -0
- data/lib/slamdown/conversion/preparer.rb +122 -0
- data/lib/slamdown/conversion/renderer.rb +432 -0
- data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
- data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
- data/lib/slamdown/conversion/rendering/html.rb +183 -0
- data/lib/slamdown/conversion/rendering/result.rb +23 -0
- data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
- data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
- data/lib/slamdown/conversion/tables.rb +676 -0
- data/lib/slamdown/conversion.rb +34 -0
- data/lib/slamdown/diagnostic.rb +34 -0
- data/lib/slamdown/document.rb +107 -0
- data/lib/slamdown/errors.rb +25 -0
- data/lib/slamdown/immutable.rb +23 -0
- data/lib/slamdown/output.rb +30 -0
- data/lib/slamdown/parsing/html.rb +227 -0
- data/lib/slamdown/parsing/node.rb +50 -0
- data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
- data/lib/slamdown/parsing.rb +17 -0
- data/lib/slamdown/version.rb +3 -0
- data/lib/slamdown.rb +10 -0
- data/readme.md +121 -0
- metadata +174 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1022c0c170ad37a1dc791fd76797ac41173aead90dea397889d74813d083cc4d
|
|
4
|
+
data.tar.gz: 3d5fbf7537c7b012a21a7a1fe8eb256b9d4bebaabb6560609b7decac053d52c9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cefee43327b751e2e646c71858ea15825f62fb9fab7cf506ebfb3e6f0db283c49b0ae0bc3b7a00987eb40e37640a0f891a6bdbdd8aa2c7dd0c744a343219216b
|
|
7
|
+
data.tar.gz: 6f82a32a29404aac296028981d8cbeb4af38db44b7842d90172f785841642e6c5c71f23546b6f8a71fd3bf9e1b834f4e6e25744cabe1932d272ea988fd8ed254
|
data/changelog.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Holds the complete validated conversion options relied upon by all later conversion stages.
|
|
7
|
+
class Configuration
|
|
8
|
+
ATTRIBUTES = [:inline, :headings, :entities, :html_indent, :urls, :tables, :elements, :classes, :ids].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader(*ATTRIBUTES)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Validates caller options and fills every omitted value from Slamdown's defaults.
|
|
14
|
+
def build(options = {})
|
|
15
|
+
ConfigurationValidator.new(options).validate
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(values)
|
|
20
|
+
ATTRIBUTES.each do |attribute|
|
|
21
|
+
instance_variable_set("@#{attribute}", Immutable.copy(values.fetch(attribute)))
|
|
22
|
+
end
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Provides a plain immutable projection for consumers that need the complete effective configuration.
|
|
27
|
+
def to_h
|
|
28
|
+
ATTRIBUTES.each_with_object({}) do |attribute, result|
|
|
29
|
+
result[attribute] = public_send(attribute)
|
|
30
|
+
end.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Performs small, section-local checks and normalisation before conversion code receives any options.
|
|
7
|
+
class ConfigurationValidator
|
|
8
|
+
TOP_LEVEL_KEYS = [:inline, :headings, :entities, :html_indent, :urls, :tables, :elements, :classes, :ids].freeze
|
|
9
|
+
HEADING_KEYS = [:start, :compact].freeze
|
|
10
|
+
URL_KEYS = [:scheme, :host, :path, :force].freeze
|
|
11
|
+
TABLE_KEYS = [:format, :max_width].freeze
|
|
12
|
+
ELEMENT_RULE_KEYS = [:action, :attributes].freeze
|
|
13
|
+
ELEMENT_ACTIONS = [:convert, :preserve, :unwrap, :inline, :drop].freeze
|
|
14
|
+
ATTRIBUTE_ACTIONS = [:preserve, :drop].freeze
|
|
15
|
+
ENTITY_MODES = [:symbolic, :character, :numeric].freeze
|
|
16
|
+
URL_FORCES = [:relative, :absolute].freeze
|
|
17
|
+
TABLE_FORMATS = [:auto, :html].freeze
|
|
18
|
+
HTML_NAME = /\A[a-z][a-z0-9._:-]*\z/.freeze
|
|
19
|
+
ATTRIBUTE_NAME = /\A[a-z_:][a-z0-9._:-]*\z/.freeze
|
|
20
|
+
HTML_WHITESPACE = /[\t\n\f\r ]/.freeze
|
|
21
|
+
|
|
22
|
+
DEFAULTS = Immutable.copy(
|
|
23
|
+
:inline => false,
|
|
24
|
+
:headings => {:start => 2, :compact => true},
|
|
25
|
+
:entities => :character,
|
|
26
|
+
:html_indent => "\t",
|
|
27
|
+
:urls => {:scheme => nil, :host => nil, :path => nil, :force => :relative},
|
|
28
|
+
:tables => {:format => :auto, :max_width => 150},
|
|
29
|
+
:elements => {},
|
|
30
|
+
:classes => {},
|
|
31
|
+
:ids => {}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def initialize(options)
|
|
35
|
+
@options = options
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate
|
|
39
|
+
expect_hash(@options, "options")
|
|
40
|
+
@options = normalise_option_keys(@options, TOP_LEVEL_KEYS, "options")
|
|
41
|
+
|
|
42
|
+
Configuration.new(
|
|
43
|
+
:inline => boolean_option(:inline),
|
|
44
|
+
:headings => validate_headings,
|
|
45
|
+
:entities => closed_option(:entities, ENTITY_MODES),
|
|
46
|
+
:html_indent => validate_html_indent,
|
|
47
|
+
:urls => validate_urls,
|
|
48
|
+
:tables => validate_tables,
|
|
49
|
+
:elements => validate_elements,
|
|
50
|
+
:classes => validate_name_rules(:classes),
|
|
51
|
+
:ids => validate_name_rules(:ids)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def value_or_default(name)
|
|
58
|
+
@options.key?(name) ? @options[name] : DEFAULTS[name]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def boolean_option(name)
|
|
62
|
+
value = value_or_default(name)
|
|
63
|
+
return value if value == true || value == false
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "#{name} must be true or false"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def closed_option(name, allowed)
|
|
69
|
+
value = value_or_default(name)
|
|
70
|
+
return value if allowed.include?(value)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{name} must be one of #{allowed.map(&:inspect).join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_headings
|
|
76
|
+
values = merged_section(:headings, HEADING_KEYS)
|
|
77
|
+
start = values[:start]
|
|
78
|
+
compact = values[:compact]
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "headings[:start] must be an integer from 1 to 6" unless start.is_a?(Integer) && (1..6).include?(start)
|
|
81
|
+
raise ArgumentError, "headings[:compact] must be true or false" unless compact == true || compact == false
|
|
82
|
+
|
|
83
|
+
values
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_html_indent
|
|
87
|
+
value = value_or_default(:html_indent)
|
|
88
|
+
valid = value.is_a?(String) && (value.empty? || value == "\t" || /\A {1,8}\z/.match(value))
|
|
89
|
+
return value if valid
|
|
90
|
+
|
|
91
|
+
raise ArgumentError, "html_indent must be empty, one tab or one to eight spaces"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_urls
|
|
95
|
+
values = merged_section(:urls, URL_KEYS)
|
|
96
|
+
|
|
97
|
+
[:scheme, :host, :path].each do |name|
|
|
98
|
+
value = values[name]
|
|
99
|
+
raise ArgumentError, "urls[:#{name}] must be a String or nil" unless value.nil? || value.is_a?(String)
|
|
100
|
+
end
|
|
101
|
+
raise ArgumentError, "urls[:force] must be :relative or :absolute" unless URL_FORCES.include?(values[:force])
|
|
102
|
+
if values[:force] == :absolute && (!usable_string?(values[:scheme]) || !usable_string?(values[:host]))
|
|
103
|
+
raise ArgumentError, "urls with force :absolute require usable scheme and host values"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
values
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_tables
|
|
110
|
+
values = merged_section(:tables, TABLE_KEYS)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "tables[:format] must be :auto or :html" unless TABLE_FORMATS.include?(values[:format])
|
|
113
|
+
max_width = values[:max_width]
|
|
114
|
+
raise ArgumentError, "tables[:max_width] must be nil or an integer of at least 20" unless max_width.nil? || (max_width.is_a?(Integer) && max_width >= 20)
|
|
115
|
+
|
|
116
|
+
values
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def merged_section(name, keys)
|
|
120
|
+
provided = @options.key?(name) ? @options[name] : {}
|
|
121
|
+
expect_hash(provided, name.to_s)
|
|
122
|
+
provided = normalise_option_keys(provided, keys, name.to_s)
|
|
123
|
+
|
|
124
|
+
DEFAULTS.fetch(name).merge(provided)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_elements
|
|
128
|
+
provided = value_or_default(:elements)
|
|
129
|
+
expect_hash(provided, "elements")
|
|
130
|
+
|
|
131
|
+
normalise_named_hash(provided, "elements") do |element_name, rule|
|
|
132
|
+
expect_hash(rule, "elements[#{element_name.inspect}]")
|
|
133
|
+
rule = normalise_option_keys(rule, ELEMENT_RULE_KEYS, "elements[#{element_name.inspect}]")
|
|
134
|
+
validate_element_rule(element_name, rule)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_element_rule(element_name, rule)
|
|
139
|
+
result = {}
|
|
140
|
+
if rule.key?(:action)
|
|
141
|
+
action = rule[:action]
|
|
142
|
+
raise ArgumentError, "invalid action for #{element_name.inspect}: #{action.inspect}" unless ELEMENT_ACTIONS.include?(action)
|
|
143
|
+
if action == :convert && (element_name == :any || !Policy::CONVERTER_NAMES.include?(element_name))
|
|
144
|
+
raise ArgumentError, "#{element_name.inspect} has no registered Slamdown converter"
|
|
145
|
+
end
|
|
146
|
+
result[:action] = action
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if rule.key?(:attributes)
|
|
150
|
+
attributes = rule[:attributes]
|
|
151
|
+
expect_hash(attributes, "attributes for #{element_name.inspect}")
|
|
152
|
+
result[:attributes] = normalise_named_hash(attributes, "attributes for #{element_name.inspect}", :attribute => true) do |attribute_name, action|
|
|
153
|
+
if ["class", "id"].include?(attribute_name)
|
|
154
|
+
raise ArgumentError, "#{attribute_name} must be configured through its dedicated section"
|
|
155
|
+
end
|
|
156
|
+
raise ArgumentError, "invalid attribute action for #{attribute_name.inspect}: #{action.inspect}" unless ATTRIBUTE_ACTIONS.include?(action)
|
|
157
|
+
action
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_name_rules(section)
|
|
165
|
+
provided = value_or_default(section)
|
|
166
|
+
expect_hash(provided, section.to_s)
|
|
167
|
+
|
|
168
|
+
normalise_named_hash(provided, section.to_s) do |element_name, matcher|
|
|
169
|
+
validate_matcher(matcher, "#{section}[#{element_name.inspect}]")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_matcher(matcher, location)
|
|
174
|
+
return :any if matcher == :any
|
|
175
|
+
|
|
176
|
+
matchers = matcher.is_a?(Array) ? matcher : [matcher]
|
|
177
|
+
matchers.each do |candidate|
|
|
178
|
+
valid_string = candidate.is_a?(String) && !candidate.empty? && !HTML_WHITESPACE.match(candidate)
|
|
179
|
+
next if valid_string || candidate.is_a?(Regexp)
|
|
180
|
+
|
|
181
|
+
raise ArgumentError, "#{location} must contain exact strings, regular expressions or :any"
|
|
182
|
+
end
|
|
183
|
+
matchers
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalise_named_hash(values, location, attribute: false)
|
|
187
|
+
result = {}
|
|
188
|
+
values.each do |source_name, value|
|
|
189
|
+
name = normalise_name(source_name, location, attribute)
|
|
190
|
+
raise ArgumentError, "#{location} contains duplicate normalised key #{name.inspect}" if result.key?(name)
|
|
191
|
+
|
|
192
|
+
result[name] = yield(name, value)
|
|
193
|
+
end
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalise_name(source_name, location, attribute)
|
|
198
|
+
unless source_name.is_a?(String) || source_name.is_a?(Symbol)
|
|
199
|
+
raise ArgumentError, "#{location} keys must be strings or symbols"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
name = source_name.to_s.downcase
|
|
203
|
+
return :any if name == "any"
|
|
204
|
+
|
|
205
|
+
pattern = attribute ? ATTRIBUTE_NAME : HTML_NAME
|
|
206
|
+
raise ArgumentError, "invalid name #{source_name.inspect} in #{location}" unless pattern.match(name)
|
|
207
|
+
|
|
208
|
+
name
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def expect_hash(value, location)
|
|
212
|
+
raise ArgumentError, "#{location} must be a Hash" unless value.is_a?(Hash)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalise_option_keys(values, allowed, location)
|
|
216
|
+
values.each_with_object({}) do |(source_name, value), result|
|
|
217
|
+
name = source_name.is_a?(String) ? source_name.to_sym : source_name
|
|
218
|
+
unless allowed.include?(name)
|
|
219
|
+
raise ArgumentError, "unknown #{location} keys: #{source_name.inspect}"
|
|
220
|
+
end
|
|
221
|
+
if result.key?(name)
|
|
222
|
+
raise ArgumentError, "#{location} contains duplicate normalised key #{name.inspect}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
result[name] = value
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def usable_string?(value)
|
|
230
|
+
value.is_a?(String) && !value.strip.empty?
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Coordinates the validated public conversion pipeline and applies the conversion error-reporting contract.
|
|
7
|
+
class Converter
|
|
8
|
+
FORMATS = {
|
|
9
|
+
"markdown" => Output::Markdown,
|
|
10
|
+
"kramdown" => Output::Kramdown
|
|
11
|
+
}.freeze
|
|
12
|
+
ERROR_MODES = [:raise, :warn, :silent].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def convert(input, format:, options: {}, errors: :warn)
|
|
16
|
+
new(format, errors).convert(input, options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(format, errors)
|
|
21
|
+
@format = normalise_format(format)
|
|
22
|
+
@output_class = FORMATS.fetch(@format)
|
|
23
|
+
validate_error_mode(errors)
|
|
24
|
+
@errors = errors
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def convert(input, options)
|
|
28
|
+
root = conversion_root(input)
|
|
29
|
+
configuration = Configuration.build(options)
|
|
30
|
+
prepared = Preparer.prepare(root, configuration)
|
|
31
|
+
normalisation = Normaliser.normalise(prepared, configuration)
|
|
32
|
+
rendering = Renderer.render(normalisation.tree, configuration, @format)
|
|
33
|
+
report_rendering_errors(rendering.diagnostics)
|
|
34
|
+
|
|
35
|
+
@output_class.new(rendering.output, normalisation.diagnostics + rendering.diagnostics)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
raise
|
|
38
|
+
rescue ConversionError
|
|
39
|
+
raise
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
handle_conversion_error(error, defined?(normalisation) && normalisation ? normalisation.diagnostics : [])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalise_format(format)
|
|
47
|
+
name = format.is_a?(Symbol) ? format.to_s : format
|
|
48
|
+
return name if name.is_a?(String) && FORMATS.key?(name)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "format must be :markdown, :kramdown, \"markdown\" or \"kramdown\""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_error_mode(errors)
|
|
54
|
+
return if ERROR_MODES.include?(errors)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "errors must be :raise, :warn or :silent"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def conversion_root(input)
|
|
60
|
+
root = input.is_a?(Document) ? input.tree : input
|
|
61
|
+
unless root.is_a?(Parsing::Node)
|
|
62
|
+
raise ArgumentError, "input must be a Slamdown document or parsed tree"
|
|
63
|
+
end
|
|
64
|
+
unless root.kind == :document || root.category == :block
|
|
65
|
+
raise ArgumentError, "input tree must be a document or block-level subtree"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
root
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def handle_conversion_error(error, diagnostics)
|
|
72
|
+
diagnostic = Diagnostic.new(
|
|
73
|
+
:code => :conversion_error,
|
|
74
|
+
:severity => :error,
|
|
75
|
+
:message => "Unable to convert document: #{error.message}",
|
|
76
|
+
:node_path => nil
|
|
77
|
+
)
|
|
78
|
+
conversion_error = ConversionError.new(diagnostic)
|
|
79
|
+
|
|
80
|
+
raise conversion_error if @errors == :raise
|
|
81
|
+
Kernel.warn(conversion_error.message) if @errors == :warn
|
|
82
|
+
|
|
83
|
+
@output_class.new("", diagnostics + [diagnostic])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def report_rendering_errors(diagnostics)
|
|
87
|
+
errors = diagnostics.select { |diagnostic| diagnostic.severity == :error }
|
|
88
|
+
return if errors.empty?
|
|
89
|
+
|
|
90
|
+
raise ConversionError.new(errors.first) if @errors == :raise
|
|
91
|
+
return unless @errors == :warn
|
|
92
|
+
|
|
93
|
+
errors.each { |diagnostic| Kernel.warn(ConversionError.new(diagnostic).message) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Carries the immutable normalised conversion tree together with the corrective diagnostics produced while building it.
|
|
7
|
+
class Normalisation
|
|
8
|
+
attr_reader :tree, :diagnostics
|
|
9
|
+
|
|
10
|
+
def initialize(tree, diagnostics)
|
|
11
|
+
raise ArgumentError, "tree must be a normalised Slamdown node" unless tree.is_a?(NormalisedNode)
|
|
12
|
+
raise ArgumentError, "diagnostics must contain only Slamdown diagnostics" unless diagnostics.is_a?(Array) && diagnostics.all? { |diagnostic| diagnostic.is_a?(Diagnostic) }
|
|
13
|
+
|
|
14
|
+
@tree = tree
|
|
15
|
+
@diagnostics = diagnostics.dup.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
# Represents one immutable, renderer-independent construct after element policy and corrective normalisation have been applied.
|
|
7
|
+
class NormalisedNode
|
|
8
|
+
ATTRIBUTES = [:kind, :source_name, :semantic_kind, :category, :value, :attributes, :classes, :id, :children, :properties, :semantic_values, :representation, :synthetic, :source_path].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader(*ATTRIBUTES)
|
|
11
|
+
|
|
12
|
+
def initialize(values)
|
|
13
|
+
ATTRIBUTES.each do |attribute|
|
|
14
|
+
instance_variable_set("@#{attribute}", Immutable.copy(values.fetch(attribute)))
|
|
15
|
+
end
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Creates a changed immutable node without making transformation code repeat every unchanged field.
|
|
20
|
+
def with(changes)
|
|
21
|
+
unknown = changes.keys - ATTRIBUTES
|
|
22
|
+
raise ArgumentError, "unknown normalised-node attributes: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
|
|
23
|
+
|
|
24
|
+
values = ATTRIBUTES.each_with_object({}) do |attribute, result|
|
|
25
|
+
result[attribute] = changes.key?(attribute) ? changes[attribute] : public_send(attribute)
|
|
26
|
+
end
|
|
27
|
+
self.class.new(values)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Provides a stable plain-Ruby projection for focused normalisation tests and later rendering diagnostics.
|
|
31
|
+
def to_h
|
|
32
|
+
ATTRIBUTES.each_with_object({}) do |attribute, result|
|
|
33
|
+
value = public_send(attribute)
|
|
34
|
+
result[attribute] = attribute == :children ? value.map(&:to_h) : value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Conversion
|
|
5
|
+
|
|
6
|
+
class Normaliser
|
|
7
|
+
# Interprets line-break runs and bubbles recovered paragraph boundaries through inline wrappers without leaking internal sentinels.
|
|
8
|
+
class Breaks
|
|
9
|
+
def initialize(node_factory, content, diagnostics)
|
|
10
|
+
@node_factory = node_factory
|
|
11
|
+
@content = content
|
|
12
|
+
@diagnostics = diagnostics
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def split_break_runs(children)
|
|
16
|
+
fragments = []
|
|
17
|
+
current = []
|
|
18
|
+
index = 0
|
|
19
|
+
|
|
20
|
+
while index < children.length
|
|
21
|
+
if paragraph_boundary?(children[index])
|
|
22
|
+
if @content.visible_content?(current) && @content.visible_content?(children[(index + 1)..-1] || [])
|
|
23
|
+
fragments << current
|
|
24
|
+
current = []
|
|
25
|
+
end
|
|
26
|
+
index += 1
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless line_break?(children[index])
|
|
31
|
+
current << children[index]
|
|
32
|
+
index += 1
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
run_path = children[index].source_path
|
|
37
|
+
break_count = 0
|
|
38
|
+
interstitial = []
|
|
39
|
+
cursor = index
|
|
40
|
+
loop do
|
|
41
|
+
if cursor < children.length && line_break?(children[cursor])
|
|
42
|
+
break_count += 1
|
|
43
|
+
cursor += 1
|
|
44
|
+
elsif cursor < children.length && @content.whitespace_text?(children[cursor]) && next_break_index(children, cursor)
|
|
45
|
+
interstitial << children[cursor]
|
|
46
|
+
cursor += 1
|
|
47
|
+
else
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
content_before = @content.visible_content?(current)
|
|
53
|
+
content_after = @content.visible_content?(children[cursor..-1] || [])
|
|
54
|
+
if break_count >= 2
|
|
55
|
+
if content_before && content_after
|
|
56
|
+
fragments << current
|
|
57
|
+
current = []
|
|
58
|
+
@diagnostics.add(:paragraph_break_recovered, :info, "Converted a repeated line-break run to a paragraph boundary", run_path)
|
|
59
|
+
end
|
|
60
|
+
elsif content_before && content_after
|
|
61
|
+
current << children[index]
|
|
62
|
+
current.concat(interstitial)
|
|
63
|
+
end
|
|
64
|
+
index = cursor
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
fragments << current if @content.visible_content?(current)
|
|
68
|
+
fragments
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wrap_inline_fragments(children, path)
|
|
72
|
+
fragments = split_inline_fragments(children)
|
|
73
|
+
fragments.each_with_index.each_with_object([]) do |(fragment, index), result|
|
|
74
|
+
result << @node_factory.paragraph_boundary(fragment.first ? fragment.first.source_path : path) if index > 0
|
|
75
|
+
result.concat(yield(fragment))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def split_inline_fragments(children)
|
|
80
|
+
return [children] unless children.any? { |child| line_break?(child) || paragraph_boundary?(child) }
|
|
81
|
+
|
|
82
|
+
split_break_runs(children)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def line_break?(node)
|
|
86
|
+
node.representation == :semantic && node.semantic_kind == :line_break
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def paragraph_boundary?(node)
|
|
90
|
+
node.representation == :boundary && node.semantic_kind == :paragraph_boundary
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def next_break_index(children, index)
|
|
96
|
+
cursor = index
|
|
97
|
+
cursor += 1 while cursor < children.length && @content.whitespace_text?(children[cursor])
|
|
98
|
+
cursor < children.length && line_break?(children[cursor])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
end
|
|
104
|
+
end
|