mdlint 0.1.0 → 0.2.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-hooks.yaml +6 -0
  3. data/CHANGELOG.md +33 -0
  4. data/README.md +136 -4
  5. data/Rakefile +14 -0
  6. data/Steepfile +17 -0
  7. data/action.yml +54 -0
  8. data/benchmark/compare.rb +49 -0
  9. data/benchmark/format.rb +29 -0
  10. data/lib/mdlint/cache_store.rb +85 -0
  11. data/lib/mdlint/cli/output_formatter.rb +150 -0
  12. data/lib/mdlint/cli.rb +268 -106
  13. data/lib/mdlint/config.rb +87 -1
  14. data/lib/mdlint/dialect.rb +53 -0
  15. data/lib/mdlint/linter/directive_filter.rb +91 -0
  16. data/lib/mdlint/linter/rule.rb +25 -5
  17. data/lib/mdlint/linter/rule_engine.rb +44 -7
  18. data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
  19. data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
  20. data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
  21. data/lib/mdlint/linter/rules/heading_style.rb +24 -2
  22. data/lib/mdlint/linter/rules/japanese.rb +201 -0
  23. data/lib/mdlint/linter/rules/line_length.rb +37 -0
  24. data/lib/mdlint/linter/rules/link_check.rb +151 -0
  25. data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
  26. data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
  27. data/lib/mdlint/linter/rules/source_style.rb +317 -0
  28. data/lib/mdlint/linter/violation.rb +16 -1
  29. data/lib/mdlint/linter.rb +9 -3
  30. data/lib/mdlint/lsp.rb +176 -0
  31. data/lib/mdlint/parallel_runner.rb +42 -0
  32. data/lib/mdlint/parser/block_parser.rb +627 -50
  33. data/lib/mdlint/parser/inline_parser.rb +259 -27
  34. data/lib/mdlint/parser/state.rb +21 -2
  35. data/lib/mdlint/parser.rb +5 -5
  36. data/lib/mdlint/plugin.rb +31 -0
  37. data/lib/mdlint/renderer/html_renderer.rb +346 -0
  38. data/lib/mdlint/renderer/md_renderer.rb +147 -11
  39. data/lib/mdlint/renderer.rb +5 -0
  40. data/lib/mdlint/text_width.rb +39 -0
  41. data/lib/mdlint/toc.rb +80 -0
  42. data/lib/mdlint/token.rb +3 -1
  43. data/lib/mdlint/version.rb +1 -1
  44. data/lib/mdlint.rb +25 -5
  45. data/script/commonmark_compatibility.rb +24 -0
  46. data/script/fetch_commonmark_spec.rb +14 -0
  47. data/sig/internal.rbs +405 -0
  48. data/sig/mdlint.rbs +107 -0
  49. metadata +26 -2
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "uri"
5
+
6
+ module Mdlint
7
+ module Renderer
8
+ class HtmlRenderer
9
+ NAMED_ENTITIES = {
10
+ "amp" => "&", "apos" => "'", "gt" => ">", "lt" => "<", "quot" => '"',
11
+ "nbsp" => "\u00a0", "copy" => "©", "reg" => "®", "trade" => "™",
12
+ "AElig" => "Æ", "aelig" => "æ", "Dcaron" => "Ď", "dcaron" => "ď",
13
+ "ouml" => "ö", "Ouml" => "Ö", "auml" => "ä", "Auml" => "Ä", "uuml" => "ü", "Uuml" => "Ü",
14
+ "frac12" => "½", "frac14" => "¼", "frac34" => "¾", "HilbertSpace" => "ℋ",
15
+ "DifferentialD" => "ⅆ", "ClockwiseContourIntegral" => "∲", "ngE" => "≧̸"
16
+ }.freeze
17
+
18
+ def initialize(options = {})
19
+ @options = options
20
+ @reference_definitions = {}
21
+ @footnote_definitions = {}
22
+ @footnote_order = []
23
+ end
24
+
25
+ def render(tokens)
26
+ @reference_definitions = {}
27
+ @footnote_definitions = {}
28
+ @footnote_order = []
29
+ collect_reference_definitions(tokens)
30
+ output = render_sequence(tokens, 0).first
31
+ output << render_footnotes unless @footnote_order.empty?
32
+ output
33
+ end
34
+
35
+ private
36
+
37
+ def render_sequence(tokens, index, closing_type = nil)
38
+ output = +""
39
+ while index < tokens.length
40
+ token = tokens[index]
41
+ if closing_type && token.type == closing_type
42
+ return [output, index + 1]
43
+ end
44
+
45
+ case token.type
46
+ when :heading_open
47
+ content, index = render_until_inline(tokens, index + 1, :heading_close)
48
+ output << "<#{token.tag}>#{render_inline(content)}</#{token.tag}>\n"
49
+ when :paragraph_open
50
+ content, index = render_until_inline(tokens, index + 1, :paragraph_close)
51
+ output << "<p>#{render_inline(content)}</p>\n"
52
+ when :bullet_list_open, :ordered_list_open
53
+ list, index = render_sequence(tokens, index + 1, token.type == :bullet_list_open ? :bullet_list_close : :ordered_list_close)
54
+ tag = token.type == :bullet_list_open ? "ul" : "ol"
55
+ start = token.type == :ordered_list_open ? token.attrs[:start].to_i : 1
56
+ start_attribute = token.type == :ordered_list_open && start != 1 ? " start=\"#{start}\"" : ""
57
+ output << "<#{tag}#{start_attribute}>\n#{list}</#{tag}>\n"
58
+ when :list_item_open
59
+ item, index = render_sequence(tokens, index + 1, :list_item_close)
60
+ task = token.attrs[:task] ? task_checkbox(token.attrs[:checked]) : ""
61
+ if token.attrs[:tight] == false
62
+ output << "<li>#{task}\n#{item}</li>\n"
63
+ else
64
+ item = collapse_tight_item(item)
65
+ output << "<li>#{task}#{item}</li>\n"
66
+ end
67
+ when :blockquote_open
68
+ content, index = render_sequence(tokens, index + 1, :blockquote_close)
69
+ if token.attrs[:alert]
70
+ alert = escape_attribute(token.attrs[:alert])
71
+ output << "<aside class=\"markdown-alert markdown-alert-#{alert}\">\n#{content}</aside>\n"
72
+ else
73
+ output << "<blockquote>\n#{content}</blockquote>\n"
74
+ end
75
+ when :fence
76
+ output << fenced_code(token)
77
+ index += 1
78
+ when :code_block
79
+ output << "<pre><code>#{escape_raw(token.content)}</code></pre>\n"
80
+ index += 1
81
+ when :math_block
82
+ output << "<div class=\"math-block\">#{escape_raw(token.content)}</div>\n"
83
+ index += 1
84
+ when :footnote_definition
85
+ @footnote_definitions[token.attrs[:label]] ||= token
86
+ index += 1
87
+ when :hr
88
+ output << "<hr />\n"
89
+ index += 1
90
+ when :html_block
91
+ output << token.content
92
+ output << "\n" unless token.content.end_with?("\n")
93
+ index += 1
94
+ when :table
95
+ output << render_table(token)
96
+ index += 1
97
+ when :directive
98
+ output << render_directive(token)
99
+ index += 1
100
+ when :front_matter, :reference_definition
101
+ index += 1
102
+ else
103
+ index += 1
104
+ end
105
+ end
106
+
107
+ [output, index]
108
+ end
109
+
110
+ def render_until_inline(tokens, index, closing_type)
111
+ inline = tokens[index..]&.find { |token| token.type == :inline }
112
+ closing_index = tokens[index..]&.index { |token| token.type == closing_type }
113
+ [inline, index + (closing_index || 0) + 1]
114
+ end
115
+
116
+ def render_inline(token)
117
+ return "" unless token
118
+ return escape(token.content) if token.children.empty?
119
+
120
+ render_inline_tokens(token.children)
121
+ end
122
+
123
+ def render_inline_tokens(tokens)
124
+ output = +""
125
+ index = 0
126
+ while index < tokens.length
127
+ token = tokens[index]
128
+ case token.type
129
+ when :text
130
+ output << escape(token.content)
131
+ when :strong_open, :em_open, :s_open
132
+ close_type = { strong_open: :strong_close, em_open: :em_close, s_open: :s_close }.fetch(token.type)
133
+ tag = { strong_open: "strong", em_open: "em", s_open: "del" }.fetch(token.type)
134
+ close_index = find_close(tokens, index, close_type)
135
+ output << "<#{tag}>#{render_inline_tokens(tokens[(index + 1)...close_index])}</#{tag}>"
136
+ index = close_index
137
+ when :code_inline
138
+ output << "<code>#{escape_raw(token.content)}</code>"
139
+ when :math_inline
140
+ output << "<span class=\"math-inline\">#{escape(token.content)}</span>"
141
+ when :footnote_ref
142
+ label = escape_attribute(token.attrs[:label])
143
+ @footnote_order << token.attrs[:label] unless @footnote_order.include?(token.attrs[:label])
144
+ number = @footnote_order.index(token.attrs[:label]) + 1
145
+ output << "<sup id=\"fnref-#{label}\"><a href=\"#fn-#{label}\">#{number}</a></sup>"
146
+ when :link_open
147
+ close_index = find_close(tokens, index, :link_close)
148
+ reference = @reference_definitions[token.attrs[:reference_label]]
149
+ unless reference || token.markup != "reference"
150
+ literal = reference_literal(token, tokens[(index + 1)...close_index])
151
+ output << escape(literal)
152
+ index = close_index
153
+ next
154
+ end
155
+ href = token.attrs[:href] || reference&.fetch(:url, "") || ""
156
+ title = token.attrs[:title] || reference&.fetch(:title, nil)
157
+ title = title.to_s.gsub(/\\([!\"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~])/, '\\1') if title
158
+ title_attribute = title ? " title=\"#{escape_attribute(title)}\"" : ""
159
+ unescape_url = token.markup != "autolink"
160
+ output << "<a href=\"#{escape_url_attribute(href, unescape: unescape_url)}\"#{title_attribute}>#{render_inline_tokens(tokens[(index + 1)...close_index])}</a>"
161
+ index = close_index
162
+ when :image
163
+ reference = @reference_definitions[token.attrs[:reference_label]]
164
+ source = token.attrs[:src] || reference&.fetch(:url, nil)
165
+ unless source
166
+ label = token.attrs[:reference_label]
167
+ suffix = token.attrs[:reference_kind] == :full ? "[#{label}]" : ""
168
+ output << escape("![#{token.attrs[:alt] || token.content}]#{suffix}")
169
+ index += 1
170
+ next
171
+ end
172
+ attrs = "src=\"#{escape_url_attribute(source, unescape: true)}\" alt=\"#{escape_attribute(image_alt(token.attrs[:alt] || token.content))}\""
173
+ title = token.attrs[:title] || reference&.fetch(:title, nil)
174
+ attrs += " title=\"#{escape_attribute(title)}\"" if title
175
+ output << "<img #{attrs} />"
176
+ when :softbreak
177
+ output << "\n"
178
+ when :hardbreak
179
+ output << "<br />\n"
180
+ when :html_inline
181
+ output << token.content
182
+ end
183
+ index += 1
184
+ end
185
+ output
186
+ end
187
+
188
+ def render_table(token)
189
+ rows = token.meta.fetch(:rows, [])
190
+ return "" if rows.empty?
191
+
192
+ header = rows.first.map { |cell| "<th>#{render_cell(cell)}</th>" }.join
193
+ body = rows.drop(1).map do |row|
194
+ cells = row.map { |cell| "<td>#{render_cell(cell)}</td>" }.join
195
+ "<tr>#{cells}</tr>\n"
196
+ end.join
197
+ "<table>\n<thead><tr>#{header}</tr></thead>\n<tbody>\n#{body}</tbody>\n</table>\n"
198
+ end
199
+
200
+ def collapse_tight_item(item)
201
+ match = item.match(/\A<p>(.*)<\/p>\n\z/m)
202
+ return match[1] if match
203
+ return "\n#{item}" if item.start_with?("<hr />\n")
204
+
205
+ item.sub(/\A<p>(.*)<\/p>\n(?=<(?:ul|ol)>)/m, "\\1\n")
206
+ end
207
+
208
+ def collect_reference_definitions(tokens)
209
+ tokens.each do |token|
210
+ next unless token.type == :reference_definition
211
+
212
+ label = token.attrs[:label]
213
+ @reference_definitions[label] ||= {
214
+ url: token.attrs[:url],
215
+ title: token.attrs[:title]
216
+ }
217
+ end
218
+ end
219
+
220
+ def render_footnotes
221
+ output = +"<section class=\"footnotes\">\n<ol>\n"
222
+ @footnote_order.each do |label|
223
+ token = @footnote_definitions[label]
224
+ content = token ? render_footnote_content(token.attrs[:content]) : ""
225
+ safe_label = escape_attribute(label)
226
+ output << "<li id=\"fn-#{safe_label}\">#{content} <a href=\"#fnref-#{safe_label}\">↩︎</a></li>\n"
227
+ end
228
+ output << "</ol>\n</section>\n"
229
+ end
230
+
231
+ def render_footnote_content(content)
232
+ inner_tokens = Parser.parse("#{content}\n", @options)
233
+ render_sequence(inner_tokens, 0).first
234
+ end
235
+
236
+ def render_directive(token)
237
+ name = token.meta[:name].to_s.downcase
238
+ return token.content unless %w[note tip important warning caution message details].include?(name)
239
+
240
+ lines = token.content.gsub(/\r\n/, "\n").split("\n", -1)
241
+ lines.shift
242
+ lines.pop while lines.last.to_s.empty?
243
+ lines.pop if lines.last.to_s.strip == ":::"
244
+ inner_source = lines.join("\n")
245
+ inner_source += "\n" unless inner_source.empty?
246
+ inner_tokens = Parser.parse(inner_source, @options)
247
+ inner_html = render_sequence(inner_tokens, 0).first
248
+ title = token.meta[:title].to_s
249
+
250
+ if name == "details"
251
+ summary = title.empty? ? "Details" : title
252
+ "<details>\n<summary>#{escape(summary)}</summary>\n#{inner_html}</details>\n"
253
+ else
254
+ title_html = title.empty? ? "" : "<p class=\"markdown-directive-title\">#{escape(title)}</p>\n"
255
+ "<aside class=\"markdown-directive markdown-directive-#{escape_attribute(name)}\">\n#{title_html}#{inner_html}</aside>\n"
256
+ end
257
+ end
258
+
259
+ def render_cell(value)
260
+ inline_tokens = Parser::InlineParser.new(@options).parse(value.to_s)
261
+ render_inline_tokens(inline_tokens)
262
+ end
263
+
264
+ def image_alt(value)
265
+ tokens = Parser::InlineParser.new(@options).parse(value.to_s)
266
+ tokens.filter_map do |token|
267
+ case token.type
268
+ when :text, :code_inline, :math_inline
269
+ token.content
270
+ when :image
271
+ token.attrs[:alt] || token.content
272
+ end
273
+ end.join
274
+ end
275
+
276
+ def fenced_code(token)
277
+ language = token.info.to_s.split.first
278
+ language = decode_entities(language.to_s).gsub(/\\([!\"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~])/, '\\1')
279
+ class_attribute = language && !language.empty? ? " class=\"language-#{escape_attribute(language)}\"" : ""
280
+ "<pre><code#{class_attribute}>#{escape_raw(token.content)}</code></pre>\n"
281
+ end
282
+
283
+ def task_checkbox(checked)
284
+ checked_attribute = checked ? " checked" : ""
285
+ "<input type=\"checkbox\" disabled#{checked_attribute}> "
286
+ end
287
+
288
+ def find_close(tokens, index, close_type)
289
+ tokens[(index + 1)..]&.index { |token| token.type == close_type }&.+(index + 1) || tokens.length
290
+ end
291
+
292
+ def reference_literal(token, inner_tokens)
293
+ inner = inner_tokens.to_a.map { |inner_token| inner_token.content || "" }.join
294
+ if token.attrs[:reference_kind] == :full
295
+ "[#{inner}][#{token.attrs[:reference_label]}]"
296
+ else
297
+ "[#{inner}]"
298
+ end
299
+ end
300
+
301
+ def escape(value)
302
+ CGI.escapeHTML(decode_entities(value.to_s)).gsub("&#39;", "'")
303
+ end
304
+
305
+ def escape_attribute(value)
306
+ CGI.escapeHTML(decode_entities(value.to_s)).gsub("`", "&#39;")
307
+ end
308
+
309
+ def escape_url_attribute(value, unescape: true)
310
+ encoded = unescape ? value.to_s.gsub(/\\([!\"#$%&'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~])/, '\\1') : value.to_s
311
+ encoded = decode_entities(encoded)
312
+ encoded = URI::DEFAULT_PARSER.escape(encoded, /[^A-Za-z0-9\-._~;\/:?@&=+$,#%!()*']/)
313
+ CGI.escapeHTML(encoded).gsub("`", "&#39;")
314
+ end
315
+
316
+ def escape_raw(value)
317
+ CGI.escapeHTML(value.to_s).gsub("&#39;", "'")
318
+ end
319
+
320
+ def decode_entities(value)
321
+ value.gsub(/&#x([0-9a-f]+);|&#([0-9]+);|&([A-Za-z][A-Za-z0-9]+);/i) do
322
+ codepoint = if Regexp.last_match(1)
323
+ Integer(Regexp.last_match(1).to_s, 16)
324
+ elsif Regexp.last_match(2)
325
+ Regexp.last_match(2).to_i
326
+ end
327
+ if codepoint
328
+ begin
329
+ if codepoint.zero?
330
+ "\uFFFD"
331
+ elsif codepoint > 0x10ffff || (0xd800..0xdfff).cover?(codepoint)
332
+ Regexp.last_match(0)
333
+ else
334
+ [codepoint].pack("U")
335
+ end
336
+ rescue RangeError
337
+ Regexp.last_match(0)
338
+ end
339
+ else
340
+ NAMED_ENTITIES.fetch(Regexp.last_match(3), Regexp.last_match(0))
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require_relative "../text_width"
4
5
 
5
6
  module Mdlint
6
7
  module Renderer
7
8
  class MdRenderer
8
- BULLET_MARKERS = %w[- *].freeze
9
+ BULLET_MARKERS = ["-"].freeze
9
10
  HORIZONTAL_RULE = "_" * 70
11
+ LINE_START_PROHIBITED = /\A[、。,.!?!?、)]|\A[)】」』〉》〕]}…]/
12
+ LINE_END_PROHIBITED = /[「『(【〈《〔[{]\z/
10
13
 
11
14
  def initialize(options = {})
12
15
  @options = options
@@ -103,6 +106,24 @@ module Mdlint
103
106
  output << token.content
104
107
  output << "\n" unless token.content.end_with?("\n")
105
108
  i += 1
109
+ when :front_matter
110
+ output << token.content
111
+ output << "\n" unless token.content.end_with?("\n")
112
+ output << "\n"
113
+ i += 1
114
+ when :directive
115
+ output << token.content
116
+ output << "\n" unless token.content.end_with?("\n")
117
+ output << "\n"
118
+ i += 1
119
+ when :table
120
+ render_table(token, output)
121
+ i += 1
122
+ when :math_block, :footnote_definition
123
+ output << token.content
124
+ output << "\n" unless token.content.end_with?("\n")
125
+ output << "\n"
126
+ i += 1
106
127
  when :reference_definition
107
128
  # Skip - will be output at the end
108
129
  i += 1
@@ -177,23 +198,57 @@ module Mdlint
177
198
  words.each do |word|
178
199
  if current_line.empty?
179
200
  current_line = word
180
- elsif (current_line.length + 1 + word.length) <= width
201
+ elsif (TextWidth.measure(current_line) + 1 + TextWidth.measure(word)) <= width
181
202
  current_line += " " + word
182
203
  else
183
- lines << current_line
204
+ lines.concat(split_to_width(current_line, width))
184
205
  current_line = word
185
206
  end
186
207
  end
187
208
 
188
- lines << current_line unless current_line.empty?
209
+ lines.concat(split_to_width(current_line, width)) unless current_line.empty?
189
210
  lines << "" if idx < paragraphs.length - 1
190
211
  end
191
212
 
192
213
  lines.join("\n")
193
214
  end
194
215
 
216
+ def split_to_width(text, width)
217
+ return [text] if TextWidth.measure(text) <= width
218
+
219
+ chunks = []
220
+ remaining = text
221
+ while !remaining.empty?
222
+ chunk = TextWidth.take(remaining, width).to_s
223
+ break if chunk.empty?
224
+
225
+ if remaining.length > chunk.length && chunk.match?(LINE_END_PROHIBITED)
226
+ moved = chunk[-1].to_s
227
+ chunk = chunk[0...-1].to_s
228
+ remaining = moved + (remaining[chunk.length + 1..] || "")
229
+ end
230
+
231
+ if remaining.match?(LINE_START_PROHIBITED)
232
+ first = remaining[0].to_s
233
+ first_width = TextWidth.measure(first)
234
+ if TextWidth.measure(chunk) + first_width <= width
235
+ chunk = chunk.to_s + first
236
+ remaining = remaining[1..] || ""
237
+ elsif chunk.length > 1
238
+ moved = chunk[-1].to_s
239
+ chunk = chunk[0...-1].to_s
240
+ remaining = moved + remaining.to_s
241
+ end
242
+ end
243
+
244
+ chunks << chunk
245
+ remaining = remaining[chunk.length..] || ""
246
+ end
247
+ chunks
248
+ end
249
+
195
250
  def render_bullet_list(tokens, start_index, output)
196
- marker = BULLET_MARKERS[@bullet_list_depth % 2]
251
+ marker = BULLET_MARKERS[@bullet_list_depth % BULLET_MARKERS.length]
197
252
  @bullet_list_depth += 1
198
253
  i = start_index + 1
199
254
 
@@ -236,6 +291,7 @@ module Mdlint
236
291
  end
237
292
 
238
293
  def render_list_item(tokens, start_index, output, prefix)
294
+ open_token = tokens[start_index]
239
295
  i = start_index + 1
240
296
  item_content = []
241
297
 
@@ -260,10 +316,70 @@ module Mdlint
260
316
  end
261
317
 
262
318
  content = item_content.join("\n").strip
263
- output << "#{prefix}#{content}\n"
319
+ task_prefix = if open_token.attrs[:task]
320
+ checked = open_token.attrs[:checked] ? "x" : " "
321
+ "[#{checked}] "
322
+ else
323
+ ""
324
+ end
325
+ output << "#{prefix}#{task_prefix}#{content}\n"
264
326
  i + 1
265
327
  end
266
328
 
329
+ def render_table(token, output)
330
+ rows = token.meta.fetch(:rows, [])
331
+ return if rows.empty?
332
+
333
+ alignments = token.meta.fetch(:alignments, [])
334
+ if @options.fetch(:table_align, true)
335
+ column_count = rows.map(&:length).max || 0
336
+ widths = column_count.times.map do |index|
337
+ [rows.map { |row| TextWidth.measure(row[index].to_s) }.max || 0, 3].max
338
+ end
339
+ output << "| #{render_table_row(rows.first, widths, alignments)} |\n"
340
+ separators = widths.each_index.map { |index| table_separator(alignments[index], widths[index]) }
341
+ output << "| #{separators.join(" | ")} |\n"
342
+ rows.drop(1).each { |row| output << "| #{render_table_row(row, widths, alignments)} |\n" }
343
+ else
344
+ header = rows.first
345
+ output << "| #{header.join(" | ")} |\n"
346
+ separators = header.each_index.map { |index| table_separator(alignments[index], 3) }
347
+ output << "| #{separators.join(" | ")} |\n"
348
+ rows.drop(1).each { |row| output << "| #{row.join(" | ")} |\n" }
349
+ end
350
+ output << "\n"
351
+ end
352
+
353
+ def render_table_row(row, widths, alignments)
354
+ widths.each_index.map do |index|
355
+ value = row[index].to_s
356
+ padding = widths[index] - TextWidth.measure(value)
357
+ case alignments[index]
358
+ when :right
359
+ " " * padding + value
360
+ when :center
361
+ left = padding / 2
362
+ " " * left + value + " " * (padding - left)
363
+ else
364
+ value + " " * padding
365
+ end
366
+ end.join(" | ")
367
+ end
368
+
369
+ def table_separator(alignment, width)
370
+ width = [width, 3].max
371
+ case alignment
372
+ when :center
373
+ ":#{"-" * width}:"
374
+ when :left
375
+ ":#{"-" * width}"
376
+ when :right
377
+ "#{"-" * width}:"
378
+ else
379
+ "-" * width
380
+ end
381
+ end
382
+
267
383
  def collect_paragraph_content(tokens, start_index, content_array)
268
384
  i = start_index + 1
269
385
  while i < tokens.length && tokens[i].type != :paragraph_close
@@ -276,6 +392,7 @@ module Mdlint
276
392
  end
277
393
 
278
394
  def render_blockquote(tokens, start_index, output)
395
+ open_token = tokens[start_index]
279
396
  i = start_index + 1
280
397
  inner_output = []
281
398
 
@@ -294,6 +411,11 @@ module Mdlint
294
411
  end
295
412
 
296
413
  quoted = inner_output.join.chomp.split("\n").map { |l| "> #{l}".rstrip }.join("\n")
414
+ if open_token.attrs[:alert]
415
+ label = open_token.attrs[:alert].upcase
416
+ quoted = "> [!#{label}]\n#{quoted}" unless quoted.empty?
417
+ quoted = "> [!#{label}]" if quoted.empty?
418
+ end
297
419
  output << "#{quoted}\n\n"
298
420
  i + 1
299
421
  end
@@ -365,6 +487,10 @@ module Mdlint
365
487
  else
366
488
  output << "#{backticks}#{content}#{backticks}"
367
489
  end
490
+ when :math_inline
491
+ output << "$#{token.content}$"
492
+ when :footnote_ref
493
+ output << "[^#{token.attrs[:label]}]"
368
494
  when :strong_open
369
495
  markup = token.markup.empty? ? "**" : token.markup
370
496
  close_index = find_close_token(tokens, i, :strong_close)
@@ -377,6 +503,11 @@ module Mdlint
377
503
  inner = render_inline_tokens(tokens[(i + 1)...close_index])
378
504
  output << "#{markup}#{inner}#{markup}"
379
505
  i = close_index
506
+ when :s_open
507
+ close_index = find_close_token(tokens, i, :s_close)
508
+ inner = render_inline_tokens(tokens[(i + 1)...close_index])
509
+ output << "~~#{inner}~~"
510
+ i = close_index
380
511
  when :link_open
381
512
  close_index = find_close_token(tokens, i, :link_close)
382
513
  inner = render_inline_tokens(tokens[(i + 1)...close_index])
@@ -413,12 +544,17 @@ module Mdlint
413
544
  i = close_index
414
545
  when :image
415
546
  alt = token.attrs[:alt] || token.content || ""
416
- src = token.attrs[:src] || ""
417
- title = token.attrs[:title]
418
- if title
419
- output << "![#{alt}](#{src} \"#{title}\")"
547
+ reference = @reference_definitions[token.attrs[:reference_label]]
548
+ src = token.attrs[:src] || reference&.fetch(:url, nil)
549
+ unless src
550
+ output << "![#{alt}]"
420
551
  else
421
- output << "![#{alt}](#{src})"
552
+ title = token.attrs[:title] || reference&.fetch(:title, nil)
553
+ if title
554
+ output << "![#{alt}](#{src} \"#{title}\")"
555
+ else
556
+ output << "![#{alt}](#{src})"
557
+ end
422
558
  end
423
559
  when :softbreak
424
560
  output << "\n"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "renderer/md_renderer"
4
+ require_relative "renderer/html_renderer"
4
5
 
5
6
  module Mdlint
6
7
  module Renderer
@@ -8,6 +9,10 @@ module Mdlint
8
9
  def render(tokens, options = {})
9
10
  MdRenderer.new(options).render(tokens)
10
11
  end
12
+
13
+ def render_html(tokens, options = {})
14
+ HtmlRenderer.new(options).render(tokens)
15
+ end
11
16
  end
12
17
  end
13
18
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlint
4
+ module TextWidth
5
+ class << self
6
+ def measure(text)
7
+ text.to_s.each_codepoint.sum { |codepoint| wide?(codepoint) ? 2 : 1 }
8
+ end
9
+
10
+ def take(text, maximum)
11
+ width = 0
12
+ result = +""
13
+ text.each_char do |character|
14
+ character_width = measure(character)
15
+ break if width + character_width > maximum
16
+
17
+ result << character
18
+ width += character_width
19
+ end
20
+ result
21
+ end
22
+
23
+ private
24
+
25
+ def wide?(codepoint)
26
+ (0x1100..0x115F).cover?(codepoint) ||
27
+ (0x2329..0x232A).cover?(codepoint) ||
28
+ (0x2E80..0xA4CF).cover?(codepoint) ||
29
+ (0xAC00..0xD7A3).cover?(codepoint) ||
30
+ (0xF900..0xFAFF).cover?(codepoint) ||
31
+ (0xFE10..0xFE19).cover?(codepoint) ||
32
+ (0xFE30..0xFE6F).cover?(codepoint) ||
33
+ (0xFF00..0xFF60).cover?(codepoint) ||
34
+ (0xFFE0..0xFFE6).cover?(codepoint) ||
35
+ (0x1F300..0x1FAFF).cover?(codepoint)
36
+ end
37
+ end
38
+ end
39
+ end