mdlint 0.1.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.
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Mdlint
6
+ module Renderer
7
+ class MdRenderer
8
+ BULLET_MARKERS = %w[- *].freeze
9
+ HORIZONTAL_RULE = "_" * 70
10
+
11
+ def initialize(options = {})
12
+ @options = options
13
+ @bullet_list_depth = 0
14
+ @reference_definitions = {}
15
+ @used_references = Set.new
16
+ end
17
+
18
+ def render(tokens)
19
+ @bullet_list_depth = 0
20
+ @reference_definitions = {}
21
+ @used_references = Set.new
22
+ @ordered_list_counter = 0
23
+
24
+ # First pass: collect all reference definitions
25
+ collect_reference_definitions(tokens)
26
+
27
+ output = []
28
+ render_tokens(tokens, output)
29
+
30
+ # Append used reference definitions at the end, sorted by label
31
+ append_reference_definitions(output)
32
+
33
+ result = output.join
34
+ result.gsub!(/\n{3,}/, "\n\n")
35
+
36
+ # Apply end of line setting
37
+ result = apply_end_of_line(result)
38
+
39
+ result.chomp!
40
+ result += "\n" unless result.empty?
41
+ result
42
+ end
43
+
44
+ def apply_end_of_line(text)
45
+ eol = @options[:end_of_line] || :lf
46
+ case eol
47
+ when :lf
48
+ text.gsub(/\r\n/, "\n")
49
+ when :crlf
50
+ text.gsub(/\r?\n/, "\r\n")
51
+ when :keep
52
+ text
53
+ else
54
+ text.gsub(/\r\n/, "\n")
55
+ end
56
+ end
57
+
58
+ def collect_reference_definitions(tokens)
59
+ tokens.each do |token|
60
+ next unless token.type == :reference_definition
61
+
62
+ label = token.attrs[:label]
63
+ # Only keep the first definition for each label (remove duplicates)
64
+ @reference_definitions[label] ||= {
65
+ url: token.attrs[:url],
66
+ title: token.attrs[:title]
67
+ }
68
+ end
69
+ end
70
+
71
+ def append_reference_definitions(output)
72
+ # mdformat converts reference links to inline links, so no definitions needed
73
+ # This method is kept for potential future use or configuration options
74
+ end
75
+
76
+ private
77
+
78
+ def render_tokens(tokens, output)
79
+ i = 0
80
+ while i < tokens.length
81
+ token = tokens[i]
82
+ case token.type
83
+ when :heading_open
84
+ i = render_heading(tokens, i, output)
85
+ when :paragraph_open
86
+ i = render_paragraph(tokens, i, output)
87
+ when :bullet_list_open
88
+ i = render_bullet_list(tokens, i, output)
89
+ when :ordered_list_open
90
+ i = render_ordered_list(tokens, i, output)
91
+ when :blockquote_open
92
+ i = render_blockquote(tokens, i, output)
93
+ when :fence
94
+ render_fence(token, output)
95
+ i += 1
96
+ when :code_block
97
+ render_code_block(token, output)
98
+ i += 1
99
+ when :hr
100
+ output << "#{HORIZONTAL_RULE}\n\n"
101
+ i += 1
102
+ when :html_block
103
+ output << token.content
104
+ output << "\n" unless token.content.end_with?("\n")
105
+ i += 1
106
+ when :reference_definition
107
+ # Skip - will be output at the end
108
+ i += 1
109
+ else
110
+ i += 1
111
+ end
112
+ end
113
+ end
114
+
115
+ def render_heading(tokens, start_index, output)
116
+ open_token = tokens[start_index]
117
+ level = open_token.tag[1].to_i
118
+ markup = "#" * level
119
+
120
+ inline_content = ""
121
+ i = start_index + 1
122
+ while i < tokens.length && tokens[i].type != :heading_close
123
+ if tokens[i].type == :inline
124
+ inline_content = render_inline(tokens[i])
125
+ end
126
+ i += 1
127
+ end
128
+
129
+ output << "#{markup} #{inline_content}\n\n"
130
+ i + 1
131
+ end
132
+
133
+ def render_paragraph(tokens, start_index, output)
134
+ inline_content = ""
135
+ i = start_index + 1
136
+ while i < tokens.length && tokens[i].type != :paragraph_close
137
+ if tokens[i].type == :inline
138
+ inline_content = render_inline(tokens[i])
139
+ end
140
+ i += 1
141
+ end
142
+
143
+ wrapped_content = wrap_text(inline_content)
144
+ output << "#{wrapped_content}\n\n"
145
+ i + 1
146
+ end
147
+
148
+ def wrap_text(text)
149
+ wrap_mode = @options[:wrap] || :keep
150
+
151
+ case wrap_mode
152
+ when :keep
153
+ text
154
+ when :no
155
+ # Remove soft line breaks, join lines
156
+ text.gsub(/\n(?!\n)/, " ")
157
+ when Integer
158
+ # Wrap at specified width
159
+ wrap_at_width(text, wrap_mode)
160
+ else
161
+ text
162
+ end
163
+ end
164
+
165
+ def wrap_at_width(text, width)
166
+ return text if width <= 0
167
+
168
+ lines = []
169
+ paragraphs = text.split(/\n\n+/)
170
+
171
+ paragraphs.each_with_index do |paragraph, idx|
172
+ # Join lines within paragraph
173
+ paragraph = paragraph.gsub(/\n(?!\n)/, " ")
174
+ words = paragraph.split(/\s+/)
175
+ current_line = ""
176
+
177
+ words.each do |word|
178
+ if current_line.empty?
179
+ current_line = word
180
+ elsif (current_line.length + 1 + word.length) <= width
181
+ current_line += " " + word
182
+ else
183
+ lines << current_line
184
+ current_line = word
185
+ end
186
+ end
187
+
188
+ lines << current_line unless current_line.empty?
189
+ lines << "" if idx < paragraphs.length - 1
190
+ end
191
+
192
+ lines.join("\n")
193
+ end
194
+
195
+ def render_bullet_list(tokens, start_index, output)
196
+ marker = BULLET_MARKERS[@bullet_list_depth % 2]
197
+ @bullet_list_depth += 1
198
+ i = start_index + 1
199
+
200
+ while i < tokens.length && tokens[i].type != :bullet_list_close
201
+ if tokens[i].type == :list_item_open
202
+ i = render_list_item(tokens, i, output, "#{marker} ")
203
+ else
204
+ i += 1
205
+ end
206
+ end
207
+
208
+ @bullet_list_depth -= 1
209
+ output << "\n" unless output.last&.end_with?("\n\n")
210
+ i + 1
211
+ end
212
+
213
+ def render_ordered_list(tokens, start_index, output)
214
+ open_token = tokens[start_index]
215
+ start_num = open_token.attrs[:start] || 1
216
+ current_num = start_num
217
+ i = start_index + 1
218
+
219
+ while i < tokens.length && tokens[i].type != :ordered_list_close
220
+ if tokens[i].type == :list_item_open
221
+ if @options[:number]
222
+ # Consecutive numbering mode
223
+ i = render_list_item(tokens, i, output, "#{current_num}. ")
224
+ current_num += 1
225
+ else
226
+ # mdformat default: all items use the same number (start number)
227
+ i = render_list_item(tokens, i, output, "#{start_num}. ")
228
+ end
229
+ else
230
+ i += 1
231
+ end
232
+ end
233
+
234
+ output << "\n" unless output.last&.end_with?("\n\n")
235
+ i + 1
236
+ end
237
+
238
+ def render_list_item(tokens, start_index, output, prefix)
239
+ i = start_index + 1
240
+ item_content = []
241
+
242
+ while i < tokens.length && tokens[i].type != :list_item_close
243
+ token = tokens[i]
244
+ case token.type
245
+ when :paragraph_open
246
+ i = collect_paragraph_content(tokens, i, item_content)
247
+ when :bullet_list_open
248
+ nested_output = []
249
+ i = render_bullet_list(tokens, i, nested_output)
250
+ nested = nested_output.join.split("\n").map { |l| " #{l}" }.join("\n")
251
+ item_content << nested
252
+ when :ordered_list_open
253
+ nested_output = []
254
+ i = render_ordered_list(tokens, i, nested_output)
255
+ nested = nested_output.join.split("\n").map { |l| " #{l}" }.join("\n")
256
+ item_content << nested
257
+ else
258
+ i += 1
259
+ end
260
+ end
261
+
262
+ content = item_content.join("\n").strip
263
+ output << "#{prefix}#{content}\n"
264
+ i + 1
265
+ end
266
+
267
+ def collect_paragraph_content(tokens, start_index, content_array)
268
+ i = start_index + 1
269
+ while i < tokens.length && tokens[i].type != :paragraph_close
270
+ if tokens[i].type == :inline
271
+ content_array << render_inline(tokens[i])
272
+ end
273
+ i += 1
274
+ end
275
+ i + 1
276
+ end
277
+
278
+ def render_blockquote(tokens, start_index, output)
279
+ i = start_index + 1
280
+ inner_output = []
281
+
282
+ while i < tokens.length && tokens[i].type != :blockquote_close
283
+ token = tokens[i]
284
+ case token.type
285
+ when :paragraph_open
286
+ i = render_paragraph(tokens, i, inner_output)
287
+ when :heading_open
288
+ i = render_heading(tokens, i, inner_output)
289
+ when :blockquote_open
290
+ i = render_blockquote(tokens, i, inner_output)
291
+ else
292
+ i += 1
293
+ end
294
+ end
295
+
296
+ quoted = inner_output.join.chomp.split("\n").map { |l| "> #{l}".rstrip }.join("\n")
297
+ output << "#{quoted}\n\n"
298
+ i + 1
299
+ end
300
+
301
+ def render_fence(token, output)
302
+ info = token.info || ""
303
+ content = token.content.chomp
304
+
305
+ # Determine the minimum number of backticks needed
306
+ backtick_count = 3
307
+ content.scan(/`+/) do |match|
308
+ backtick_count = [backtick_count, match.length + 1].max
309
+ end
310
+
311
+ marker = "`" * backtick_count
312
+
313
+ output << "#{marker}#{info}\n"
314
+ output << "#{content}\n"
315
+ output << "#{marker}\n\n"
316
+ end
317
+
318
+ def render_code_block(token, output)
319
+ # Convert indented code blocks to fenced code blocks (mdformat style)
320
+ content = token.content.chomp
321
+
322
+ # Determine the minimum number of backticks needed
323
+ backtick_count = 3
324
+ content.scan(/`+/) do |match|
325
+ backtick_count = [backtick_count, match.length + 1].max
326
+ end
327
+
328
+ marker = "`" * backtick_count
329
+
330
+ output << "#{marker}\n"
331
+ output << "#{content}\n"
332
+ output << "#{marker}\n\n"
333
+ end
334
+
335
+ def render_inline(token)
336
+ return token.content if token.children.empty?
337
+
338
+ render_inline_tokens(token.children)
339
+ end
340
+
341
+ def render_inline_tokens(tokens)
342
+ output = []
343
+ i = 0
344
+
345
+ while i < tokens.length
346
+ token = tokens[i]
347
+ case token.type
348
+ when :text
349
+ output << token.content
350
+ when :code_inline
351
+ content = token.content
352
+ # Strip unnecessary leading/trailing spaces (unless content contains backticks)
353
+ unless content.include?("`")
354
+ content = content.strip
355
+ end
356
+ # Determine minimum backticks needed
357
+ backtick_count = 1
358
+ content.scan(/`+/) do |match|
359
+ backtick_count = [backtick_count, match.length + 1].max
360
+ end
361
+ backticks = "`" * backtick_count
362
+ # Add space padding if content starts/ends with backtick
363
+ if content.start_with?("`") || content.end_with?("`")
364
+ output << "#{backticks} #{content} #{backticks}"
365
+ else
366
+ output << "#{backticks}#{content}#{backticks}"
367
+ end
368
+ when :strong_open
369
+ markup = token.markup.empty? ? "**" : token.markup
370
+ close_index = find_close_token(tokens, i, :strong_close)
371
+ inner = render_inline_tokens(tokens[(i + 1)...close_index])
372
+ output << "#{markup}#{inner}#{markup}"
373
+ i = close_index
374
+ when :em_open
375
+ markup = token.markup.empty? ? "*" : token.markup
376
+ close_index = find_close_token(tokens, i, :em_close)
377
+ inner = render_inline_tokens(tokens[(i + 1)...close_index])
378
+ output << "#{markup}#{inner}#{markup}"
379
+ i = close_index
380
+ when :link_open
381
+ close_index = find_close_token(tokens, i, :link_close)
382
+ inner = render_inline_tokens(tokens[(i + 1)...close_index])
383
+
384
+ if token.markup == "autolink"
385
+ href = token.attrs[:href] || ""
386
+ output << "<#{href}>"
387
+ elsif token.markup == "reference"
388
+ # Reference link - resolve from definitions
389
+ label = token.attrs[:reference_label]
390
+ ref = @reference_definitions[label]
391
+ if ref
392
+ @used_references << label
393
+ formatted_href = format_link_url(ref[:url])
394
+ if ref[:title]
395
+ output << "[#{inner}](#{formatted_href} \"#{ref[:title]}\")"
396
+ else
397
+ output << "[#{inner}](#{formatted_href})"
398
+ end
399
+ else
400
+ # Reference not found, keep as-is
401
+ output << "[#{inner}]"
402
+ end
403
+ else
404
+ href = token.attrs[:href] || ""
405
+ title = token.attrs[:title]
406
+ formatted_href = format_link_url(href)
407
+ if title
408
+ output << "[#{inner}](#{formatted_href} \"#{title}\")"
409
+ else
410
+ output << "[#{inner}](#{formatted_href})"
411
+ end
412
+ end
413
+ i = close_index
414
+ when :image
415
+ alt = token.attrs[:alt] || token.content || ""
416
+ src = token.attrs[:src] || ""
417
+ title = token.attrs[:title]
418
+ if title
419
+ output << "![#{alt}](#{src} \"#{title}\")"
420
+ else
421
+ output << "![#{alt}](#{src})"
422
+ end
423
+ when :softbreak
424
+ output << "\n"
425
+ when :hardbreak
426
+ output << "\\\n"
427
+ when :html_inline
428
+ output << token.content
429
+ end
430
+ i += 1
431
+ end
432
+
433
+ output.join
434
+ end
435
+
436
+ def find_close_token(tokens, start_index, close_type)
437
+ i = start_index + 1
438
+ while i < tokens.length
439
+ return i if tokens[i].type == close_type
440
+
441
+ i += 1
442
+ end
443
+ tokens.length
444
+ end
445
+
446
+ def format_link_url(url)
447
+ # Remove angle brackets if present and not needed
448
+ url = url.gsub(/^<|>$/, "")
449
+ # Add angle brackets only if URL contains spaces or parentheses
450
+ if url.match?(/[\s()]/)
451
+ "<#{url}>"
452
+ else
453
+ url
454
+ end
455
+ end
456
+ end
457
+ end
458
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderer/md_renderer"
4
+
5
+ module Mdlint
6
+ module Renderer
7
+ class << self
8
+ def render(tokens, options = {})
9
+ MdRenderer.new(options).render(tokens)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlint
4
+ class Token
5
+ attr_accessor :type, :tag, :nesting, :level, :content, :markup, :info, :meta, :map, :children, :attrs
6
+
7
+ def initialize(type:, **kwargs)
8
+ @type = type
9
+ @tag = kwargs[:tag]
10
+ @nesting = kwargs[:nesting] || 0
11
+ @level = kwargs[:level] || 0
12
+ @content = kwargs[:content] || ""
13
+ @markup = kwargs[:markup] || ""
14
+ @info = kwargs[:info] || ""
15
+ @meta = kwargs[:meta] || {}
16
+ @map = kwargs[:map]
17
+ @children = kwargs[:children] || []
18
+ @attrs = kwargs[:attrs] || {}
19
+ end
20
+
21
+ def opening?
22
+ @nesting == 1
23
+ end
24
+
25
+ def closing?
26
+ @nesting == -1
27
+ end
28
+
29
+ def self_closing?
30
+ @nesting.zero?
31
+ end
32
+
33
+ def block?
34
+ BLOCK_TYPES.include?(@type)
35
+ end
36
+
37
+ def inline?
38
+ INLINE_TYPES.include?(@type)
39
+ end
40
+
41
+ BLOCK_TYPES = %i[
42
+ document
43
+ heading_open heading_close
44
+ paragraph_open paragraph_close
45
+ bullet_list_open bullet_list_close
46
+ ordered_list_open ordered_list_close
47
+ list_item_open list_item_close
48
+ blockquote_open blockquote_close
49
+ code_block fence
50
+ hr html_block
51
+ inline
52
+ ].freeze
53
+
54
+ INLINE_TYPES = %i[
55
+ text
56
+ strong_open strong_close
57
+ em_open em_close
58
+ code_inline
59
+ link_open link_close
60
+ image
61
+ softbreak hardbreak
62
+ html_inline
63
+ ].freeze
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlint
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mdlint.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mdlint/version"
4
+ require_relative "mdlint/token"
5
+ require_relative "mdlint/parser"
6
+ require_relative "mdlint/renderer"
7
+ require_relative "mdlint/linter"
8
+
9
+ module Mdlint
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def format(src, options = {})
14
+ tokens = Parser.parse(src)
15
+ Renderer.render(tokens, options)
16
+ end
17
+
18
+ def format_file(path, options = {})
19
+ src = File.read(path)
20
+ formatted = format(src, options)
21
+
22
+ if options[:check]
23
+ src != formatted
24
+ else
25
+ File.write(path, formatted) unless options[:diff]
26
+ formatted
27
+ end
28
+ end
29
+
30
+ def parse(src)
31
+ Parser.parse(src)
32
+ end
33
+
34
+ def lint(src, options = {})
35
+ Linter.check(src, options)
36
+ end
37
+
38
+ def lint_file(path, options = {})
39
+ src = File.read(path)
40
+ lint(src, options)
41
+ end
42
+ end
43
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mdlint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Mdlint is a Pure Ruby Markdown linter and formatter with no external
13
+ dependencies. It provides configurable lint rules and automatic formatting for consistent
14
+ Markdown files.
15
+ email:
16
+ - t.yudai92@gmail.com
17
+ executables:
18
+ - mdlint
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - exe/mdlint
27
+ - lib/mdlint.rb
28
+ - lib/mdlint/cli.rb
29
+ - lib/mdlint/config.rb
30
+ - lib/mdlint/linter.rb
31
+ - lib/mdlint/linter/rule.rb
32
+ - lib/mdlint/linter/rule_engine.rb
33
+ - lib/mdlint/linter/rules/first_line_heading.rb
34
+ - lib/mdlint/linter/rules/heading_increment.rb
35
+ - lib/mdlint/linter/rules/heading_style.rb
36
+ - lib/mdlint/linter/rules/no_multiple_blanks.rb
37
+ - lib/mdlint/linter/rules/no_trailing_spaces.rb
38
+ - lib/mdlint/linter/violation.rb
39
+ - lib/mdlint/parser.rb
40
+ - lib/mdlint/parser/block_parser.rb
41
+ - lib/mdlint/parser/inline_parser.rb
42
+ - lib/mdlint/parser/state.rb
43
+ - lib/mdlint/renderer.rb
44
+ - lib/mdlint/renderer/md_renderer.rb
45
+ - lib/mdlint/token.rb
46
+ - lib/mdlint/version.rb
47
+ homepage: https://github.com/ydah/mdlint
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ allowed_push_host: https://rubygems.org
52
+ homepage_uri: https://github.com/ydah/mdlint
53
+ source_code_uri: https://github.com/ydah/mdlint
54
+ changelog_uri: https://github.com/ydah/mdlint/blob/main/CHANGELOG.md
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.2.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.4
71
+ specification_version: 4
72
+ summary: A Pure Ruby Markdown linter and formatter
73
+ test_files: []