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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
|
|
5
|
+
# Describes one construction or conversion observation without exposing mutable internal state.
|
|
6
|
+
class Diagnostic
|
|
7
|
+
SEVERITIES = [:info, :warning, :error].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :code, :severity, :message, :node_path
|
|
10
|
+
|
|
11
|
+
def initialize(code:, severity:, message:, node_path: nil)
|
|
12
|
+
raise ArgumentError, "diagnostic code must be a Symbol" unless code.is_a?(Symbol)
|
|
13
|
+
raise ArgumentError, "invalid diagnostic severity: #{severity.inspect}" unless SEVERITIES.include?(severity)
|
|
14
|
+
raise ArgumentError, "diagnostic message must be a String" unless message.is_a?(String)
|
|
15
|
+
raise ArgumentError, "diagnostic node_path must contain only non-negative integers" unless valid_node_path?(node_path)
|
|
16
|
+
|
|
17
|
+
@code = code
|
|
18
|
+
@severity = severity
|
|
19
|
+
@message = message.dup.freeze
|
|
20
|
+
@node_path = node_path && node_path.dup.freeze
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def valid_node_path?(node_path)
|
|
27
|
+
return true if node_path.nil?
|
|
28
|
+
return false unless node_path.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
node_path.all? { |index| index.is_a?(Integer) && index >= 0 }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
|
|
5
|
+
# Owns Slamdown's immutable, format-neutral representation of one HTML input.
|
|
6
|
+
class Document
|
|
7
|
+
ERROR_MODES = [:raise, :warn, :silent].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :tree, :diagnostics
|
|
10
|
+
|
|
11
|
+
def initialize(input, errors: :warn)
|
|
12
|
+
validate_error_mode(errors)
|
|
13
|
+
construct(input, errors)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Converts a document or block-level parsed subtree through the selected output pipeline.
|
|
18
|
+
def convert(input, format:, options: {}, errors: :warn)
|
|
19
|
+
Conversion::Converter.convert(input, :format => format, :options => options, :errors => errors)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns only the rendered Markdown string for the common convenience path.
|
|
24
|
+
def to_markdown(options = {}, controls = {})
|
|
25
|
+
convert_convenience(:markdown, options, controls)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns only the rendered Kramdown string for the common convenience path.
|
|
29
|
+
def to_kramdown(options = {}, controls = {})
|
|
30
|
+
convert_convenience(:kramdown, options, controls)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def convert_convenience(format, options, controls)
|
|
36
|
+
if controls.empty? && options.is_a?(Hash) && options.keys == [:errors]
|
|
37
|
+
controls = options
|
|
38
|
+
options = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
self.class.convert(self, :format => format, :options => options, :errors => conversion_errors(controls)).output
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def conversion_errors(controls)
|
|
45
|
+
unless controls.is_a?(Hash) && (controls.keys - [:errors]).empty?
|
|
46
|
+
raise ArgumentError, "conversion controls accept only errors"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
controls.fetch(:errors, :warn)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def construct(input, errors)
|
|
53
|
+
@tree, parser_warnings = Parsing.parse(input.to_s)
|
|
54
|
+
raise "the parser did not return a usable document tree" unless usable_tree?(@tree)
|
|
55
|
+
|
|
56
|
+
@diagnostics = parser_warnings.map { |message| parser_warning(message) }.freeze
|
|
57
|
+
freeze
|
|
58
|
+
rescue StandardError => error
|
|
59
|
+
handle_parse_error(error, errors)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_error_mode(errors)
|
|
63
|
+
return if ERROR_MODES.include?(errors)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "errors must be :raise, :warn or :silent"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def usable_tree?(tree)
|
|
69
|
+
tree.is_a?(Parsing::Node) && tree.kind == :document
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parser_warning(message)
|
|
73
|
+
Diagnostic.new(
|
|
74
|
+
:code => :parser_warning,
|
|
75
|
+
:severity => :warning,
|
|
76
|
+
:message => message.to_s,
|
|
77
|
+
:node_path => nil
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_parse_error(error, errors)
|
|
82
|
+
diagnostic = Diagnostic.new(
|
|
83
|
+
:code => :parse_error,
|
|
84
|
+
:severity => :error,
|
|
85
|
+
:message => "Unable to parse HTML: #{error.message}",
|
|
86
|
+
:node_path => nil
|
|
87
|
+
)
|
|
88
|
+
parse_error = ParseError.new(diagnostic)
|
|
89
|
+
|
|
90
|
+
raise parse_error if errors == :raise
|
|
91
|
+
Kernel.warn(parse_error.message) if errors == :warn
|
|
92
|
+
|
|
93
|
+
@tree = empty_tree
|
|
94
|
+
@diagnostics = [diagnostic].freeze
|
|
95
|
+
freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def empty_tree
|
|
99
|
+
Parsing::Node.new(
|
|
100
|
+
:kind => :document,
|
|
101
|
+
:children => [],
|
|
102
|
+
:properties => {:encoding => "UTF-8"}
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
|
|
5
|
+
# Reports a construction failure together with the diagnostic exposed by non-raising error modes.
|
|
6
|
+
class ParseError < StandardError
|
|
7
|
+
attr_reader :diagnostic
|
|
8
|
+
|
|
9
|
+
def initialize(diagnostic)
|
|
10
|
+
@diagnostic = diagnostic
|
|
11
|
+
super(diagnostic.message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Reports a conversion failure. Conversion stages populate this error as they are implemented.
|
|
16
|
+
class ConversionError < StandardError
|
|
17
|
+
attr_reader :diagnostic
|
|
18
|
+
|
|
19
|
+
def initialize(diagnostic)
|
|
20
|
+
@diagnostic = diagnostic
|
|
21
|
+
super(diagnostic.message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
|
|
5
|
+
# Copies nested public values into an immutable representation shared by Slamdown's boundary objects.
|
|
6
|
+
module Immutable
|
|
7
|
+
def self.copy(value)
|
|
8
|
+
case value
|
|
9
|
+
when String, Regexp
|
|
10
|
+
value.dup.freeze
|
|
11
|
+
when Array
|
|
12
|
+
value.map { |item| copy(item) }.freeze
|
|
13
|
+
when Hash
|
|
14
|
+
value.each_with_object({}) do |(name, item), result|
|
|
15
|
+
result[copy(name)] = copy(item)
|
|
16
|
+
end.freeze
|
|
17
|
+
else
|
|
18
|
+
value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
|
|
5
|
+
# Contains the immutable public values returned by full conversions.
|
|
6
|
+
module Output
|
|
7
|
+
# Owns the canonical string and conversion-only diagnostics shared by every output format.
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :output, :diagnostics
|
|
10
|
+
|
|
11
|
+
def initialize(output, diagnostics)
|
|
12
|
+
raise ArgumentError, "output must be a String" unless output.is_a?(String)
|
|
13
|
+
raise ArgumentError, "diagnostics must contain only Slamdown diagnostics" unless diagnostics.is_a?(Array) && diagnostics.all? { |diagnostic| diagnostic.is_a?(Diagnostic) }
|
|
14
|
+
|
|
15
|
+
@output = output.dup.freeze
|
|
16
|
+
@diagnostics = diagnostics.dup.freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Identifies output rendered through Slamdown's restricted Markdown profile.
|
|
22
|
+
class Markdown < Base
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Identifies output rendered through Slamdown's specified Kramdown extensions.
|
|
26
|
+
class Kramdown < Base
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown/parser/html"
|
|
5
|
+
|
|
6
|
+
module Slamdown
|
|
7
|
+
module Parsing
|
|
8
|
+
|
|
9
|
+
# Parses HTML with Kramdown while retaining the explicit name of every source element that survives Kramdown's native conversion.
|
|
10
|
+
class Html < ::Kramdown::Parser::Html
|
|
11
|
+
# Extends only converter instances selected by Slamdown. Ordinary Kramdown parsing continues to instantiate its original converter class.
|
|
12
|
+
class SourceAwareElementConverter < ::Kramdown::Parser::Html::ElementConverter
|
|
13
|
+
CODE_SOURCE_NAMES = %w[code kbd pre samp tt var].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(root)
|
|
16
|
+
@raw_elements = {}
|
|
17
|
+
@raw_list_children = {}
|
|
18
|
+
capture_raw_element(root)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Captures the source name and contains a failure of Kramdown's optional native conversion to the affected element.
|
|
23
|
+
def process(element, do_conversion = true, preserve_text = false, parent = nil)
|
|
24
|
+
source_name = element.value if element.type == :html_element
|
|
25
|
+
raw_element = @raw_elements[element.object_id] if source_name
|
|
26
|
+
result = begin
|
|
27
|
+
super
|
|
28
|
+
rescue StandardError
|
|
29
|
+
raise unless source_name && do_conversion && @raw_elements.key?(element.object_id)
|
|
30
|
+
|
|
31
|
+
restore_raw_element(element)
|
|
32
|
+
process_html_element(element, false, preserve_text)
|
|
33
|
+
end
|
|
34
|
+
element.options[:source_name] = source_name if source_name
|
|
35
|
+
restore_list_text(element) if ["ol", "ul"].include?(source_name)
|
|
36
|
+
attach_code_source(element, raw_element) if CODE_SOURCE_NAMES.include?(source_name)
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Retains code text before Kramdown can flatten element boundaries or leave raw code text in child nodes.
|
|
43
|
+
def attach_code_source(element, raw_element)
|
|
44
|
+
state = {:value => String.new, :structural_boundary => false, :explicit_break => false}
|
|
45
|
+
raw_element.children.each { |child| append_code_source(child, state) }
|
|
46
|
+
element.options[:code_source] = decoded_code_source(raw_element, state[:value])
|
|
47
|
+
element.options[:code_source_has_break] = state[:explicit_break]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def append_code_source(element, state)
|
|
51
|
+
if element.type == :text
|
|
52
|
+
flush_structural_boundary(state)
|
|
53
|
+
state[:value] << element.value.to_s
|
|
54
|
+
elsif element.type == :html_element && element.value.to_s.downcase == "br"
|
|
55
|
+
state[:structural_boundary] = false
|
|
56
|
+
state[:value] << "\n"
|
|
57
|
+
state[:explicit_break] = true
|
|
58
|
+
elsif element.type == :html_element
|
|
59
|
+
block = HTML_BLOCK_ELEMENTS.include?(element.value.to_s.downcase)
|
|
60
|
+
state[:structural_boundary] = true if block
|
|
61
|
+
element.children.each { |child| append_code_source(child, state) }
|
|
62
|
+
state[:structural_boundary] = true if block
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def flush_structural_boundary(state)
|
|
67
|
+
if state[:structural_boundary] && !state[:value].empty? && !state[:value].end_with?("\n")
|
|
68
|
+
state[:value] << "\n"
|
|
69
|
+
end
|
|
70
|
+
state[:structural_boundary] = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Reuses the active Kramdown version's entity handling instead of duplicating dependency-specific decoding rules.
|
|
74
|
+
def decoded_code_source(raw_element, value)
|
|
75
|
+
return "" if value.empty?
|
|
76
|
+
|
|
77
|
+
copy = duplicate_element_fields(raw_element)
|
|
78
|
+
copy.children = [::Kramdown::Element.new(:text, value)]
|
|
79
|
+
convert_code(copy)
|
|
80
|
+
copy.value.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capture_raw_element(element)
|
|
84
|
+
if element.type == :html_element && ["ol", "ul"].include?(element.value.to_s.downcase)
|
|
85
|
+
@raw_list_children[element.object_id] = element.children.map do |child|
|
|
86
|
+
child.type == :text ? [:text, child.value.to_s] : [:child, child.object_id]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
copy = duplicate_element_fields(element)
|
|
90
|
+
@raw_elements[element.object_id] = copy
|
|
91
|
+
copy.children = element.children.map { |child| capture_raw_element(child) }
|
|
92
|
+
copy
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Kramdown discards direct text children of lists; retain non-blank runs as synthetic paragraphs in their source position.
|
|
96
|
+
def restore_list_text(element)
|
|
97
|
+
descriptors = @raw_list_children[element.object_id]
|
|
98
|
+
return unless descriptors
|
|
99
|
+
|
|
100
|
+
current = element.children.each_with_object({}) { |child, result| result[child.object_id] = child }
|
|
101
|
+
children = descriptors.each_with_object([]) do |(kind, value), result|
|
|
102
|
+
if kind == :child
|
|
103
|
+
child = current.delete(value)
|
|
104
|
+
result << child if child
|
|
105
|
+
elsif !value.to_s.strip.empty?
|
|
106
|
+
paragraph = ::Kramdown::Element.new(:p, nil, nil, :category => :block, :transparent => true)
|
|
107
|
+
paragraph.children << ::Kramdown::Element.new(:text, value)
|
|
108
|
+
result << paragraph
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
element.children = children + current.values
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def restore_raw_element(element)
|
|
115
|
+
raw = duplicate_element(@raw_elements.fetch(element.object_id))
|
|
116
|
+
element.type = raw.type
|
|
117
|
+
element.value = raw.value
|
|
118
|
+
element.attr.replace(raw.attr)
|
|
119
|
+
element.options.replace(raw.options)
|
|
120
|
+
element.children = raw.children
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def duplicate_element(element)
|
|
124
|
+
copy = duplicate_element_fields(element)
|
|
125
|
+
copy.children = element.children.map { |child| duplicate_element(child) }
|
|
126
|
+
copy
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def duplicate_element_fields(element)
|
|
130
|
+
::Kramdown::Element.new(
|
|
131
|
+
element.type,
|
|
132
|
+
duplicate_value(element.value),
|
|
133
|
+
duplicate_value(element.attr),
|
|
134
|
+
duplicate_value(element.options)
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def duplicate_value(value)
|
|
139
|
+
case value
|
|
140
|
+
when String
|
|
141
|
+
value.dup
|
|
142
|
+
when Array
|
|
143
|
+
value.map { |item| duplicate_value(item) }
|
|
144
|
+
when Hash
|
|
145
|
+
result = value.dup
|
|
146
|
+
result.clear
|
|
147
|
+
value.each_with_object(result) do |(key, item), copy|
|
|
148
|
+
copy[duplicate_value(key)] = duplicate_value(item)
|
|
149
|
+
end
|
|
150
|
+
else
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Reproduces Kramdown::Parser::Html#parse's orchestration so the existing scanner can feed Slamdown's converter without global mutation.
|
|
157
|
+
def parse
|
|
158
|
+
@stack, @tree = [], @root
|
|
159
|
+
@src = ::Kramdown::Utils::StringScanner.new(adapt_source(repair_unclosed_table_rows(source)))
|
|
160
|
+
|
|
161
|
+
while true
|
|
162
|
+
if (result = @src.scan(/\s*#{HTML_INSTRUCTION_RE}/o))
|
|
163
|
+
@tree.children << ::Kramdown::Element.new(:xml_pi, result.strip, nil, :category => :block)
|
|
164
|
+
elsif @src.scan(/\s*#{HTML_DOCTYPE_RE}/o)
|
|
165
|
+
# Kramdown deliberately discards the document type.
|
|
166
|
+
elsif (result = @src.scan(/\s*#{HTML_COMMENT_RE}/o))
|
|
167
|
+
@tree.children << ::Kramdown::Element.new(:xml_comment, result.strip, nil, :category => :block)
|
|
168
|
+
else
|
|
169
|
+
break
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
tag_handler = lambda do |child, closed, handle_body|
|
|
174
|
+
parse_raw_html(child, &tag_handler) if !closed && handle_body
|
|
175
|
+
end
|
|
176
|
+
parse_raw_html(@tree, &tag_handler)
|
|
177
|
+
|
|
178
|
+
SourceAwareElementConverter.convert(@tree)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
# Browsers implicitly close a row when another row starts; Kramdown instead nests it and can expose closing markup as text.
|
|
184
|
+
def repair_unclosed_table_rows(value)
|
|
185
|
+
return value unless value.valid_encoding?
|
|
186
|
+
|
|
187
|
+
table_rows = []
|
|
188
|
+
cursor = 0
|
|
189
|
+
result = String.new
|
|
190
|
+
value.to_enum(:scan, /<\/?\s*(?:table|tr)\b[^>]*>/i).each do
|
|
191
|
+
match = Regexp.last_match
|
|
192
|
+
result << value[cursor...match.begin(0)]
|
|
193
|
+
tag = match[0]
|
|
194
|
+
closing = /\A<\//.match(tag)
|
|
195
|
+
name = tag[/\A<\/?\s*([A-Za-z]+)/, 1].downcase
|
|
196
|
+
|
|
197
|
+
if name == "table"
|
|
198
|
+
if closing
|
|
199
|
+
result << "</tr>" if table_rows.last
|
|
200
|
+
table_rows.pop
|
|
201
|
+
else
|
|
202
|
+
table_rows << false
|
|
203
|
+
end
|
|
204
|
+
result << tag
|
|
205
|
+
elsif !table_rows.empty? && closing
|
|
206
|
+
unless table_rows.last
|
|
207
|
+
cursor = match.end(0)
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
table_rows[-1] = false
|
|
211
|
+
result << tag
|
|
212
|
+
elsif !table_rows.empty?
|
|
213
|
+
result << "</tr>" if table_rows.last
|
|
214
|
+
table_rows[-1] = true
|
|
215
|
+
result << tag
|
|
216
|
+
else
|
|
217
|
+
result << tag
|
|
218
|
+
end
|
|
219
|
+
cursor = match.end(0)
|
|
220
|
+
end
|
|
221
|
+
result << value[cursor..-1].to_s
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Parsing
|
|
5
|
+
|
|
6
|
+
# Immutable, format-neutral representation of one parsed construct. It contains parser facts only; element policy, structural correction and rendering belong to later stages.
|
|
7
|
+
class Node
|
|
8
|
+
ATTRIBUTES = [
|
|
9
|
+
:kind,
|
|
10
|
+
:source_name,
|
|
11
|
+
:semantic_kind,
|
|
12
|
+
:category,
|
|
13
|
+
:content_model,
|
|
14
|
+
:value,
|
|
15
|
+
:attributes,
|
|
16
|
+
:children,
|
|
17
|
+
:properties,
|
|
18
|
+
:source_form,
|
|
19
|
+
:synthetic
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader(*ATTRIBUTES)
|
|
23
|
+
|
|
24
|
+
def initialize(kind:, source_name: nil, semantic_kind: nil, category: nil, content_model: nil, value: nil, attributes: [], children: [], properties: {}, source_form: nil, synthetic: false)
|
|
25
|
+
@kind = kind
|
|
26
|
+
@source_name = Immutable.copy(source_name)
|
|
27
|
+
@semantic_kind = semantic_kind
|
|
28
|
+
@category = category
|
|
29
|
+
@content_model = content_model
|
|
30
|
+
@value = Immutable.copy(value)
|
|
31
|
+
@attributes = Immutable.copy(attributes)
|
|
32
|
+
@children = children.dup.freeze
|
|
33
|
+
@properties = Immutable.copy(properties)
|
|
34
|
+
@source_form = Immutable.copy(source_form)
|
|
35
|
+
@synthetic = synthetic
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Provides a stable plain-Ruby projection for diagnostics and characterisation.
|
|
40
|
+
def to_h
|
|
41
|
+
ATTRIBUTES.each_with_object({}) do |attribute, result|
|
|
42
|
+
value = public_send(attribute)
|
|
43
|
+
result[attribute] = attribute == :children ? value.map(&:to_h) : value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slamdown
|
|
4
|
+
module Parsing
|
|
5
|
+
|
|
6
|
+
# Projects Kramdown elements into immutable Slamdown nodes while removing only dependency-specific representation details from the parser boundary.
|
|
7
|
+
class ParsedNodeAdapter
|
|
8
|
+
SEMANTIC_KINDS = {
|
|
9
|
+
:p => :paragraph,
|
|
10
|
+
:header => :heading,
|
|
11
|
+
:blockquote => :blockquote,
|
|
12
|
+
:codeblock => :code_block,
|
|
13
|
+
:ul => :unordered_list,
|
|
14
|
+
:ol => :ordered_list,
|
|
15
|
+
:li => :list_item,
|
|
16
|
+
:dl => :definition_list,
|
|
17
|
+
:dt => :definition_term,
|
|
18
|
+
:dd => :definition_description,
|
|
19
|
+
:hr => :horizontal_rule,
|
|
20
|
+
:table => :table,
|
|
21
|
+
:thead => :table_head,
|
|
22
|
+
:tbody => :table_body,
|
|
23
|
+
:tfoot => :table_foot,
|
|
24
|
+
:tr => :table_row,
|
|
25
|
+
:td => :table_cell,
|
|
26
|
+
:a => :link,
|
|
27
|
+
:br => :line_break,
|
|
28
|
+
:img => :image,
|
|
29
|
+
:codespan => :code_span,
|
|
30
|
+
:em => :emphasis,
|
|
31
|
+
:strong => :strong,
|
|
32
|
+
:math => :math
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
NON_ELEMENT_KINDS = {
|
|
36
|
+
:root => :document,
|
|
37
|
+
:text => :text,
|
|
38
|
+
:raw => :raw,
|
|
39
|
+
:xml_comment => :comment,
|
|
40
|
+
:xml_pi => :processing_instruction,
|
|
41
|
+
:entity => :entity,
|
|
42
|
+
:typographic_sym => :typographic_symbol,
|
|
43
|
+
:smart_quote => :smart_quote
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
def adapt(root)
|
|
48
|
+
new.adapt_node(root)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Recursively copies only fields that belong to Slamdown's parser contract.
|
|
53
|
+
def adapt_node(element, parent_type: nil, final_root_child: false)
|
|
54
|
+
source_name = explicit_source_name(element)
|
|
55
|
+
children = element.children.each_with_index.map do |child, index|
|
|
56
|
+
adapt_node(
|
|
57
|
+
child,
|
|
58
|
+
:parent_type => element.type,
|
|
59
|
+
:final_root_child => element.type == :root && index == element.children.length - 1
|
|
60
|
+
)
|
|
61
|
+
end.freeze
|
|
62
|
+
|
|
63
|
+
Node.new(
|
|
64
|
+
:kind => NON_ELEMENT_KINDS.fetch(element.type, :element),
|
|
65
|
+
:source_name => source_name,
|
|
66
|
+
:semantic_kind => SEMANTIC_KINDS[element.type],
|
|
67
|
+
:category => ::Kramdown::Element.category(element),
|
|
68
|
+
:content_model => element.options[:content_model],
|
|
69
|
+
:value => adapted_value(element, parent_type, final_root_child),
|
|
70
|
+
:attributes => adapted_attributes(element),
|
|
71
|
+
:children => children,
|
|
72
|
+
:properties => adapted_properties(element),
|
|
73
|
+
:source_form => adapted_source_form(element),
|
|
74
|
+
:synthetic => synthetic_element?(element, source_name)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def explicit_source_name(element)
|
|
81
|
+
name = element.options[:source_name]
|
|
82
|
+
name && name.downcase
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def adapted_value(element, parent_type, final_root_child)
|
|
86
|
+
case element.type
|
|
87
|
+
when :raw, :xml_comment, :xml_pi, :html_element
|
|
88
|
+
nil
|
|
89
|
+
when :entity
|
|
90
|
+
{
|
|
91
|
+
:code_point => element.value.code_point,
|
|
92
|
+
:name => element.value.name,
|
|
93
|
+
:character => element.value.char
|
|
94
|
+
}
|
|
95
|
+
when :text
|
|
96
|
+
text = element.value
|
|
97
|
+
text = normalised_root_text(text, final_root_child) if parent_type == :root
|
|
98
|
+
text
|
|
99
|
+
else
|
|
100
|
+
element.value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Kramdown 2.3.0 leaves root text adapted but unprocessed, whereas 2.5.2 collapses it and exposes the parser's terminal sentinel as a space.
|
|
105
|
+
def normalised_root_text(text, final_root_child)
|
|
106
|
+
result = text.gsub(/\s+/, " ")
|
|
107
|
+
result = result.sub(/\s+\z/, "") if final_root_child
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def adapted_attributes(element)
|
|
112
|
+
element.attr.map do |name, value|
|
|
113
|
+
[name.downcase, value]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def adapted_properties(element)
|
|
118
|
+
properties = {}
|
|
119
|
+
properties[:encoding] = element.options[:encoding].name if element.options[:encoding]
|
|
120
|
+
properties[:level] = element.options[:level] if element.options.key?(:level)
|
|
121
|
+
properties[:alignment] = element.options[:alignment] if element.options.key?(:alignment)
|
|
122
|
+
properties[:code_source] = element.options[:code_source] if element.options.key?(:code_source)
|
|
123
|
+
properties[:code_source_has_break] = element.options[:code_source_has_break] if element.options.key?(:code_source_has_break)
|
|
124
|
+
properties
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def adapted_source_form(element)
|
|
128
|
+
return unless element.type == :entity && element.options[:original]
|
|
129
|
+
|
|
130
|
+
element.options[:original]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def synthetic_element?(element, source_name)
|
|
134
|
+
return false unless NON_ELEMENT_KINDS.fetch(element.type, :element) == :element
|
|
135
|
+
return false if source_name
|
|
136
|
+
return true if element.type == :tbody || (element.type == :p && element.options[:transparent])
|
|
137
|
+
|
|
138
|
+
raise "Kramdown element #{element.type.inspect} lacks source provenance"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parsing/html"
|
|
4
|
+
require_relative "parsing/node"
|
|
5
|
+
require_relative "parsing/parsed_node_adapter"
|
|
6
|
+
|
|
7
|
+
module Slamdown
|
|
8
|
+
module Parsing
|
|
9
|
+
|
|
10
|
+
# Parses HTML through Slamdown's isolated Kramdown extension and returns the format-neutral tree together with any parser warnings.
|
|
11
|
+
def self.parse(source, options = {})
|
|
12
|
+
root, warnings = Html.parse(source, options)
|
|
13
|
+
[ParsedNodeAdapter.adapt(root), warnings]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/slamdown.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "slamdown/version"
|
|
4
|
+
require "slamdown/immutable"
|
|
5
|
+
require "slamdown/diagnostic"
|
|
6
|
+
require "slamdown/errors"
|
|
7
|
+
require "slamdown/parsing"
|
|
8
|
+
require "slamdown/output"
|
|
9
|
+
require "slamdown/document"
|
|
10
|
+
require "slamdown/conversion"
|