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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/changelog.md +7 -0
  3. data/lib/slamdown/conversion/configuration.rb +35 -0
  4. data/lib/slamdown/conversion/configuration_validator.rb +235 -0
  5. data/lib/slamdown/conversion/converter.rb +98 -0
  6. data/lib/slamdown/conversion/normalisation.rb +21 -0
  7. data/lib/slamdown/conversion/normalised_node.rb +40 -0
  8. data/lib/slamdown/conversion/normaliser/breaks.rb +104 -0
  9. data/lib/slamdown/conversion/normaliser/content.rb +174 -0
  10. data/lib/slamdown/conversion/normaliser/correction_diagnostics.rb +29 -0
  11. data/lib/slamdown/conversion/normaliser/element_actions.rb +423 -0
  12. data/lib/slamdown/conversion/normaliser/heading_levels.rb +45 -0
  13. data/lib/slamdown/conversion/normaliser/inline_semantics.rb +218 -0
  14. data/lib/slamdown/conversion/normaliser/node_factory.rb +112 -0
  15. data/lib/slamdown/conversion/normaliser/url_rewriter.rb +124 -0
  16. data/lib/slamdown/conversion/normaliser.rb +58 -0
  17. data/lib/slamdown/conversion/policy.rb +178 -0
  18. data/lib/slamdown/conversion/prepared_node.rb +23 -0
  19. data/lib/slamdown/conversion/preparer.rb +122 -0
  20. data/lib/slamdown/conversion/renderer.rb +432 -0
  21. data/lib/slamdown/conversion/rendering/entity_encoder.rb +56 -0
  22. data/lib/slamdown/conversion/rendering/escaper.rb +58 -0
  23. data/lib/slamdown/conversion/rendering/html.rb +183 -0
  24. data/lib/slamdown/conversion/rendering/result.rb +23 -0
  25. data/lib/slamdown/conversion/rendering/strategy.rb +157 -0
  26. data/lib/slamdown/conversion/semantic_extractor.rb +97 -0
  27. data/lib/slamdown/conversion/tables.rb +676 -0
  28. data/lib/slamdown/conversion.rb +34 -0
  29. data/lib/slamdown/diagnostic.rb +34 -0
  30. data/lib/slamdown/document.rb +107 -0
  31. data/lib/slamdown/errors.rb +25 -0
  32. data/lib/slamdown/immutable.rb +23 -0
  33. data/lib/slamdown/output.rb +30 -0
  34. data/lib/slamdown/parsing/html.rb +227 -0
  35. data/lib/slamdown/parsing/node.rb +50 -0
  36. data/lib/slamdown/parsing/parsed_node_adapter.rb +144 -0
  37. data/lib/slamdown/parsing.rb +17 -0
  38. data/lib/slamdown/version.rb +3 -0
  39. data/lib/slamdown.rb +10 -0
  40. data/readme.md +121 -0
  41. metadata +174 -0
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+ module Rendering
6
+
7
+ # Reconstructs canonical HTML islands solely from normalised nodes and post-policy attributes.
8
+ class Html
9
+ SEMANTIC_TAGS = {
10
+ :paragraph => "p",
11
+ :blockquote => "blockquote",
12
+ :ordered_list => "ol",
13
+ :unordered_list => "ul",
14
+ :list_item => "li",
15
+ :definition_list => "dl",
16
+ :definition_term => "dt",
17
+ :definition_description => "dd",
18
+ :horizontal_rule => "hr",
19
+ :line_break => "br",
20
+ :code_span => "code",
21
+ :code_block => "pre",
22
+ :link => "a",
23
+ :image => "img",
24
+ :emphasis => "em",
25
+ :strong => "strong",
26
+ :deletion => "del",
27
+ :table => "table",
28
+ :table_head => "thead",
29
+ :table_body => "tbody",
30
+ :table_foot => "tfoot",
31
+ :table_row => "tr",
32
+ :table_cell => "td",
33
+ :table_caption => "caption",
34
+ :table_colgroup => "colgroup",
35
+ :table_column => "col"
36
+ }.freeze
37
+ BLOCK_TAGS = %w[address article aside blockquote div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hr li main nav ol p pre section table thead tbody tfoot tr td th ul].freeze
38
+ VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
39
+
40
+ def initialize(configuration, entity_encoder)
41
+ @indent = configuration.html_indent
42
+ @inline = configuration.inline
43
+ @entity_encoder = entity_encoder
44
+ end
45
+
46
+ def render(node)
47
+ block_node?(node) ? render_block(node, 0) : render_inline(node)
48
+ end
49
+
50
+ def render_inline(node)
51
+ return escape_text(node.value) if node.kind == :text
52
+ return render_entity(node) if entity_leaf?(node)
53
+
54
+ tag = tag_for(node)
55
+ return node.children.map { |child| render_inline(child) }.join unless tag
56
+ return start_tag(node, tag, true) if void_tag?(tag)
57
+ return render_code_span(node, tag) if node.semantic_kind == :code_span
58
+
59
+ "#{start_tag(node, tag)}#{node.children.map { |child| render_inline(child) }.join}</#{tag}>"
60
+ end
61
+
62
+ private
63
+
64
+ def render_block(node, depth)
65
+ tag = tag_for(node)
66
+ return render_block_children(node.children, depth) unless tag
67
+ return indent(depth) + start_tag(node, tag, true) if void_tag?(tag)
68
+ return render_preserved_code_block(node, tag, depth) if node.semantic_kind == :code_block && node.representation == :html
69
+ return render_code_block(node, depth) if node.semantic_kind == :code_block && node.representation != :html
70
+
71
+ if node.children.none? { |child| block_node?(child) }
72
+ return "#{indent(depth)}#{start_tag(node, tag)}#{node.children.map { |child| render_inline(child) }.join}</#{tag}>"
73
+ end
74
+
75
+ lines = [indent(depth) + start_tag(node, tag)]
76
+ lines.concat(render_mixed_children(node.children, depth + 1))
77
+ lines << "#{indent(depth)}</#{tag}>"
78
+ lines.join("\n")
79
+ end
80
+
81
+ def render_block_children(children, depth)
82
+ render_mixed_children(children, depth).join("\n")
83
+ end
84
+
85
+ def render_mixed_children(children, depth)
86
+ lines = []
87
+ inline_run = []
88
+ flush = lambda do
89
+ unless inline_run.empty?
90
+ lines << indent(depth) + inline_run.map { |child| render_inline(child) }.join
91
+ inline_run = []
92
+ end
93
+ end
94
+
95
+ children.each do |child|
96
+ if block_node?(child)
97
+ flush.call
98
+ lines << render_block(child, depth)
99
+ else
100
+ inline_run << child
101
+ end
102
+ end
103
+ flush.call
104
+ lines
105
+ end
106
+
107
+ def render_code_span(node, tag)
108
+ value = node.value.to_s
109
+ value = value.gsub(/\s+/, " ") if @inline
110
+ "#{start_tag(node, tag)}#{escape_text(value)}</#{tag}>"
111
+ end
112
+
113
+ def render_code_block(node, depth)
114
+ tag = tag_for(node)
115
+ language = node.semantic_values[:code_language]
116
+ code_attributes = language ? " class=\"#{escape_attribute("language-#{language}")}\"" : ""
117
+ "#{indent(depth)}#{start_tag(node, tag)}<code#{code_attributes}>#{escape_text(node.value.to_s)}</code></#{tag}>"
118
+ end
119
+
120
+ def render_preserved_code_block(node, tag, depth)
121
+ "#{indent(depth)}#{start_tag(node, tag)}#{escape_text(node.value.to_s)}</#{tag}>"
122
+ end
123
+
124
+ def start_tag(node, tag, void = false)
125
+ ending = void ? " />" : ">"
126
+ "<#{tag}#{render_attributes(node)}#{ending}"
127
+ end
128
+
129
+ def render_attributes(node)
130
+ pairs = []
131
+ pairs << ["id", node.id] if node.id
132
+ pairs << ["class", node.classes.join(" ")] unless node.classes.empty?
133
+ pairs.concat(node.attributes.reject { |name, _value| ["id", "class"].include?(name) }.sort_by(&:first))
134
+ return "" if pairs.empty?
135
+
136
+ " " + pairs.map { |name, value| "#{name}=\"#{escape_attribute(value)}\"" }.join(" ")
137
+ end
138
+
139
+ def tag_for(node)
140
+ return node.source_name if node.representation == :html && node.source_name
141
+ return "th" if node.semantic_kind == :table_cell && node.source_name == "th"
142
+ return "h#{node.properties[:level]}" if node.semantic_kind == :heading
143
+
144
+ SEMANTIC_TAGS[node.semantic_kind]
145
+ end
146
+
147
+ def block_node?(node)
148
+ return true if node.category == :block
149
+
150
+ tag = tag_for(node)
151
+ tag && BLOCK_TAGS.include?(tag)
152
+ end
153
+
154
+ def void_tag?(tag)
155
+ VOID_TAGS.include?(tag)
156
+ end
157
+
158
+ def entity_leaf?(node)
159
+ [:entity, :typographic_symbol, :smart_quote].include?(node.kind)
160
+ end
161
+
162
+ def render_entity(node)
163
+ value = @entity_encoder.encode(node)
164
+ @entity_encoder.character_mode? ? escape_text(value) : value
165
+ end
166
+
167
+ def escape_text(value)
168
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
169
+ end
170
+
171
+ def escape_attribute(value)
172
+ value = value.to_s.gsub(/[\r\n]+/, " ") if @inline
173
+ escape_text(value).gsub('"', "&quot;")
174
+ end
175
+
176
+ def indent(depth)
177
+ @indent * depth
178
+ end
179
+ end
180
+
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+ module Rendering
6
+
7
+ # Carries one rendered document together with node-local failures contained during traversal.
8
+ class Result
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
+ end
22
+ end
23
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+ module Rendering
6
+
7
+ # Supplies the small set of output-format differences used by the shared renderer.
8
+ module Strategy
9
+ class << self
10
+ def build(format)
11
+ format == "kramdown" ? Kramdown.new : Markdown.new
12
+ end
13
+ end
14
+
15
+ # Defines common format hooks and groups definition-list terms with their following descriptions.
16
+ class Base
17
+ def pad_space_bounded_code_span?
18
+ false
19
+ end
20
+
21
+ def html_for_space_bounded_code_span?
22
+ false
23
+ end
24
+
25
+ def escape_link_ampersands?
26
+ true
27
+ end
28
+
29
+ def escape_definition_marker?
30
+ false
31
+ end
32
+
33
+ def decorate_inline(_node, syntax)
34
+ syntax
35
+ end
36
+
37
+ def decorate_block(_node, syntax)
38
+ syntax
39
+ end
40
+
41
+ private
42
+
43
+ def definition_groups(node)
44
+ groups = []
45
+ terms = []
46
+ descriptions = []
47
+ flush = lambda do
48
+ groups << [terms, descriptions] unless terms.empty? && descriptions.empty?
49
+ terms = []
50
+ descriptions = []
51
+ end
52
+
53
+ node.children.each do |child|
54
+ if child.semantic_kind == :definition_term
55
+ flush.call unless descriptions.empty?
56
+ terms << child
57
+ elsif child.semantic_kind == :definition_description
58
+ descriptions << child
59
+ end
60
+ end
61
+ flush.call
62
+ groups
63
+ end
64
+
65
+ def prefix_lines(value, first_prefix, continuation_prefix)
66
+ lines = value.split("\n", -1)
67
+ lines.each_with_index.map do |line, index|
68
+ prefix = index.zero? ? first_prefix : continuation_prefix
69
+ line.empty? ? prefix.rstrip : prefix + line
70
+ end.join("\n")
71
+ end
72
+ end
73
+
74
+ # Implements definition-list degradation for the restricted Markdown profile.
75
+ class Markdown < Base
76
+ def pad_space_bounded_code_span?
77
+ true
78
+ end
79
+
80
+ def render_definition_list(renderer, node)
81
+ groups = definition_groups(node).map do |terms, descriptions|
82
+ term = terms.map do |item|
83
+ renderer.render_inlines(item.children, :suppress_strong => true, :line_start => false).strip
84
+ end.join("<br />")
85
+ lines = ["* **#{term}**"]
86
+ descriptions.each do |description|
87
+ content = renderer.render_container_contents(description)
88
+ lines << prefix_lines(content, " * ", " ")
89
+ end
90
+ lines.join("\n")
91
+ end
92
+ groups.join("\n")
93
+ end
94
+ end
95
+
96
+ # Adds Kramdown IALs and emits native definition-list syntax while sharing all ordinary rendering primitives.
97
+ class Kramdown < Base
98
+ IAL_TOKEN = /\A[A-Za-z_][A-Za-z0-9_-]*\z/.freeze
99
+
100
+ def html_for_space_bounded_code_span?
101
+ true
102
+ end
103
+
104
+ def escape_definition_marker?
105
+ true
106
+ end
107
+
108
+ def decorate_inline(node, syntax)
109
+ attribute_list = ial(node)
110
+ attribute_list ? syntax + attribute_list : syntax
111
+ end
112
+
113
+ def decorate_block(node, syntax)
114
+ attribute_list = ial(node)
115
+ attribute_list ? "#{syntax}\n#{attribute_list}" : syntax
116
+ end
117
+
118
+ def render_definition_list(renderer, node)
119
+ groups = definition_groups(node).map do |terms, descriptions|
120
+ lines = terms.map { |term| renderer.render_inlines(term.children, :line_start => true).strip }
121
+ descriptions.each do |description|
122
+ content = renderer.render_container_contents(description)
123
+ lines << prefix_lines(content, ": ", " ")
124
+ end
125
+ lines.join("\n")
126
+ end
127
+ decorate_block(node, groups.join("\n\n"))
128
+ end
129
+
130
+ private
131
+
132
+ def ial(node)
133
+ return if !node.id && node.classes.empty?
134
+
135
+ parts = []
136
+ parts << ial_id(node.id) if node.id
137
+ if node.classes.all? { |name| IAL_TOKEN.match(name) }
138
+ parts.concat(node.classes.map { |name| ".#{name}" })
139
+ elsif !node.classes.empty?
140
+ parts << "class=\"#{quoted(node.classes.join(" "))}\""
141
+ end
142
+ "{: #{parts.join(" ")}}"
143
+ end
144
+
145
+ def ial_id(value)
146
+ IAL_TOKEN.match(value) ? "##{value}" : "id=\"#{quoted(value)}\""
147
+ end
148
+
149
+ def quoted(value)
150
+ value.gsub("\\", "\\\\").gsub('"', '\\"')
151
+ end
152
+ end
153
+ end
154
+
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slamdown
4
+ module Conversion
5
+
6
+ # Reads the small registered set of semantic source attributes before retention policy can discard those attributes.
7
+ class SemanticExtractor
8
+ HTML_WHITESPACE = /[\t\n\f\r ]+/.freeze
9
+ LANGUAGE_CLASS = /\Alanguage-([A-Za-z0-9+_.-]+)\z/.freeze
10
+ RECOGNISED_STYLE_PROPERTIES = %w[font-weight font-style text-decoration].freeze
11
+
12
+ def extract(node)
13
+ attributes = Hash[node.attributes]
14
+ values = {}
15
+
16
+ extract_image_fallback(node, attributes, values)
17
+ extract_span_styles(node, attributes, values)
18
+ extract_code_language(node, attributes, values)
19
+ extract_table_spans(node, attributes, values)
20
+
21
+ values
22
+ end
23
+
24
+ # Language classes become fence metadata and must not also survive as presentation classes.
25
+ def consumed_class?(node, token)
26
+ ["code", "pre"].include?(node.source_name) && LANGUAGE_CLASS.match(token)
27
+ end
28
+
29
+ private
30
+
31
+ def extract_image_fallback(node, attributes, values)
32
+ return unless node.source_name == "img" && !attributes.key?("alt")
33
+
34
+ title = attributes["title"]
35
+ values[:image_fallback_alt] = title if title.is_a?(String) && !title.strip.empty?
36
+ end
37
+
38
+ def extract_span_styles(node, attributes, values)
39
+ return unless node.source_name == "span" && attributes["style"].is_a?(String)
40
+
41
+ declarations = recognised_declarations(attributes["style"])
42
+ styles = []
43
+ styles << :strong if bold?(declarations["font-weight"])
44
+ styles << :emphasis if declarations["font-style"] == "italic"
45
+
46
+ decorations = declarations.fetch("text-decoration", "").split(HTML_WHITESPACE)
47
+ styles << :deletion if decorations.include?("line-through")
48
+ styles << :underline if decorations.include?("underline")
49
+ values[:styled_as] = styles unless styles.empty?
50
+ end
51
+
52
+ def recognised_declarations(style)
53
+ style.split(";").each_with_object({}) do |declaration, result|
54
+ property, value = declaration.split(":", 2)
55
+ next unless property && value
56
+
57
+ property = property.strip.downcase
58
+ value = value.strip.downcase
59
+ next unless RECOGNISED_STYLE_PROPERTIES.include?(property) && !value.empty?
60
+
61
+ # Every syntactically usable declaration replaces the previous value, even when its value has no Slamdown semantic equivalent.
62
+ result[property] = value
63
+ end
64
+ end
65
+
66
+ def bold?(value)
67
+ return true if ["bold", "bolder"].include?(value)
68
+ return false unless /\A\d+\z/.match(value)
69
+
70
+ value.to_i > 400
71
+ end
72
+
73
+ def extract_code_language(node, attributes, values)
74
+ return unless ["code", "pre"].include?(node.source_name)
75
+
76
+ attributes.fetch("class", "").split(HTML_WHITESPACE).each do |token|
77
+ match = LANGUAGE_CLASS.match(token)
78
+ next unless match
79
+
80
+ values[:code_language] = match[1]
81
+ break
82
+ end
83
+ end
84
+
85
+ def extract_table_spans(node, attributes, values)
86
+ return unless ["td", "th"].include?(node.source_name)
87
+
88
+ spans = ["colspan", "rowspan"].each_with_object({}) do |name, result|
89
+ value = attributes[name]
90
+ result[name] = value if value.is_a?(String)
91
+ end
92
+ values[:table_spans] = spans unless spans.empty?
93
+ end
94
+ end
95
+
96
+ end
97
+ end