Class: Ace::Lint::Molecules::MarkdownLinter

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/lint/molecules/markdown_linter.rb

Overview

Validates markdown syntax via kramdown

Constant Summary collapse

EM_DASH =

Typography characters to detect

"\u2014"
SMART_QUOTES =
[
  "\u201C", # Left double quotation mark "
  "\u201D", # Right double quotation mark "
  "\u2018", # Left single quotation mark '
  "\u2019"  # Right single quotation mark '
].freeze
FENCE_PATTERN =

Fenced code block pattern (“‘ or ~~~, with optional up to 3 leading spaces per CommonMark) Captures the fence character and length for proper matching

/^(\s{0,3})(`{3,}|~{3,})/
INLINE_CODE_PATTERN =

Inline code pattern - handles single and double backtick spans

/``[^`]+``|`[^`]+`/
HEADING_PATTERN =
/^\#{1,6}\s+\S/
UNORDERED_LIST_PATTERN =
/^[*-]\s+\S/

Class Method Summary collapse

Class Method Details

.check_markdown_style(content) ⇒ Array<Models::ValidationError>

Check markdown style best practices

Parameters:

  • content (String)

    Markdown content

Returns:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 93

def self.check_markdown_style(content)
  warnings = []
  lines = content.lines
  each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
    idx = index
    line_num = idx + 1
    prev_line = idx.positive? ? lines[idx - 1] : nil
    next_line = lines[idx + 1]

    case transition
    when :open
      if prev_line && !prev_line.strip.empty?
        warnings << Models::ValidationError.new(
          line: line_num,
          message: "Missing blank line before code block",
          severity: :warning
        )
      end
      next
    when :close
      if next_line && !next_line.strip.empty?
        warnings << Models::ValidationError.new(
          line: line_num + 1,
          message: "Missing blank line after code block",
          severity: :warning
        )
      end
      next
    end

    # Skip style checks inside fenced code blocks.
    next if in_code_block

    # Check: trailing whitespace
    if line_has_trailing_whitespace?(line)
      warnings << Models::ValidationError.new(
        line: line_num,
        message: "Trailing whitespace found",
        severity: :warning
      )
    end

    # Check: blank line before headers
    if line.match?(HEADING_PATTERN) && prev_line && !prev_line.strip.empty?
      warnings << Models::ValidationError.new(
        line: line_num,
        message: "Missing blank line before heading",
        severity: :warning
      )
    end

    # Check: blank line after headers
    if line.match?(HEADING_PATTERN) && next_line && !next_line.strip.empty?
      warnings << Models::ValidationError.new(
        line: line_num,
        message: "Missing blank line after heading",
        severity: :warning
      )
    end

    # Check: blank line before lists (unless first line or after another list item)
    if line.match?(UNORDERED_LIST_PATTERN) && prev_line && !prev_line.strip.empty? && !prev_line.match?(UNORDERED_LIST_PATTERN)
      warnings << Models::ValidationError.new(
        line: line_num,
        message: "Missing blank line before list",
        severity: :warning
      )
    end

    # Check: blank line after lists
    if prev_line&.match?(UNORDERED_LIST_PATTERN) && !line.match?(UNORDERED_LIST_PATTERN) && !line.strip.empty?
      warnings << Models::ValidationError.new(
        line: line_num,
        message: "Missing blank line after list",
        severity: :warning
      )
    end
  end

  # Check: file should end with newline
  unless content.end_with?("\n")
    warnings << Models::ValidationError.new(
      message: "Missing trailing newline at end of file",
      severity: :warning
    )
  end

  warnings
end

.check_typography(content, config) ⇒ Array<Models::ValidationError>

Check typography issues (em-dashes, smart quotes) Skips content inside fenced code blocks and inline code

Parameters:

  • content (String)

    Markdown content

  • config (Hash)

    Markdown configuration with typography settings

Returns:



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 239

def self.check_typography(content, config)
  issues = []
  typography_config = config["typography"] || {}
  em_dash_severity = typography_config["em_dash"] || "warn"
  smart_quotes_severity = typography_config["smart_quotes"] || "warn"

  # Return early if both checks are disabled
  return issues if em_dash_severity == "off" && smart_quotes_severity == "off"

  lines = content.lines

  each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
    line_num = index + 1

    next if transition || in_code_block

    # Remove inline code spans (handles both single and double backticks)
    # Then remove link markup but keep link text for checking
    line_without_code = strip_link_markup(line.gsub(INLINE_CODE_PATTERN, ""))

    # Check for em-dashes
    if em_dash_severity != "off" && line_without_code.include?(EM_DASH)
      severity = (em_dash_severity == "error") ? :error : :warning
      issues << Models::ValidationError.new(
        line: line_num,
        message: "Em-dash character found; use double hyphens (--) instead",
        severity: severity
      )
    end

    # Check for smart quotes
    if smart_quotes_severity != "off"
      SMART_QUOTES.each do |quote|
        if line_without_code.include?(quote)
          severity = (smart_quotes_severity == "error") ? :error : :warning
          quote_type = ["\u201C", "\u201D"].include?(quote) ? "double" : "single"
          issues << Models::ValidationError.new(
            line: line_num,
            message: "Smart #{quote_type} quote (#{quote}) found; use ASCII quotes instead",
            severity: severity
          )
        end
      end
    end
  end

  issues
end

.each_fence_aware_line(lines) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 288

def self.each_fence_aware_line(lines)
  return enum_for(:each_fence_aware_line, lines) unless block_given?

  in_code_block = false
  fence_char = nil
  fence_length = 0

  lines.each_with_index do |line, idx|
    transition = nil
    opened_fence_char = nil
    opened_fence_length = nil

    if (match = line.match(FENCE_PATTERN))
      current_fence_char = match[2][0]
      current_fence_length = match[2].length

      if in_code_block
        transition = :close if current_fence_char == fence_char && current_fence_length >= fence_length
      else
        transition = :open
        opened_fence_char = current_fence_char
        opened_fence_length = current_fence_length
      end
    end

    yield(
      line: line,
      index: idx,
      in_code_block: in_code_block,
      transition: transition
    )

    case transition
    when :open
      in_code_block = true
      fence_char = opened_fence_char
      fence_length = opened_fence_length
    when :close
      in_code_block = false
      fence_char = nil
      fence_length = 0
    end
  end
end


196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 196

def self.each_markdown_link(text)
  return enum_for(:each_markdown_link, text) unless block_given?

  cursor = 0
  while (open_bracket = text.index("[", cursor))
    close_bracket = find_matching_closer(text, open_bracket, "[", "]")
    if close_bracket && text[close_bracket + 1] == "("
      close_paren = find_matching_closer(text, close_bracket + 1, "(", ")")
      if close_paren
        yield(
          start: open_bracket,
          end_exclusive: close_paren + 1,
          link_text: text[(open_bracket + 1)...close_bracket],
          destination: text[(close_bracket + 2)...close_paren]
        )
        cursor = close_paren + 1
        next
      end
    end

    cursor = open_bracket + 1
  end
end

.escaped_character?(text, index) ⇒ Boolean

Returns:

  • (Boolean)


356
357
358
359
360
361
362
363
364
365
366
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 356

def self.escaped_character?(text, index)
  backslashes = 0
  idx = index - 1

  while idx >= 0 && text[idx] == "\\"
    backslashes += 1
    idx -= 1
  end

  backslashes.odd?
end

.find_matching_closer(text, opener_index, opener_char, closer_char) ⇒ Object



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 333

def self.find_matching_closer(text, opener_index, opener_char, closer_char)
  depth = 0
  idx = opener_index

  while idx < text.length
    char = text[idx]
    if escaped_character?(text, idx)
      idx += 1
      next
    end

    if char == opener_char
      depth += 1
    elsif char == closer_char
      depth -= 1
      return idx if depth.zero?
    end
    idx += 1
  end

  nil
end

.line_has_trailing_whitespace?(line) ⇒ Boolean

Returns:

  • (Boolean)


183
184
185
186
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 183

def self.line_has_trailing_whitespace?(line)
  line_without_newline = line.sub(/\r?\n\z/, "")
  line_without_newline.match?(/[ \t]+\z/)
end

.lint(file_path, options: {}) ⇒ Models::LintResult

Validate markdown file

Parameters:

  • file_path (String)

    Path to markdown file

  • options (Hash) (defaults to: {})

    Kramdown options

Returns:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 33

def self.lint(file_path, options: {})
  content = File.read(file_path)
  lint_content(file_path, content, options: options)
rescue Errno::ENOENT
  Models::LintResult.new(
    file_path: file_path,
    success: false,
    errors: [Models::ValidationError.new(message: "File not found: #{file_path}")]
  )
rescue => e
  Models::LintResult.new(
    file_path: file_path,
    success: false,
    errors: [Models::ValidationError.new(message: "Error reading file: #{e.message}")]
  )
end

.lint_content(file_path, content, options: {}) ⇒ Models::LintResult

Validate markdown content

Parameters:

  • file_path (String)

    Path for reference

  • content (String)

    Markdown content

  • options (Hash) (defaults to: {})

    Kramdown options

Returns:



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 55

def self.lint_content(file_path, content, options: {})
  markdown_content = strip_frontmatter(content)
  result = Atoms::KramdownParser.parse(markdown_content, options: options)

  errors = result[:errors].map do |msg|
    Models::ValidationError.new(message: msg, severity: :error)
  end

  warnings = result[:warnings].map do |msg|
    Models::ValidationError.new(message: msg, severity: :warning)
  end

  # Add style checks
  style_warnings = check_markdown_style(markdown_content)
  warnings.concat(style_warnings)

  # Add typography checks
  config = Ace::Lint.markdown_config
  typography_issues = check_typography(markdown_content, config)
  typography_issues.each do |issue|
    if issue.severity == :error
      errors << issue
    else
      warnings << issue
    end
  end

  Models::LintResult.new(
    file_path: file_path,
    success: result[:success] && errors.empty?,
    errors: errors,
    warnings: warnings
  )
end

.strip_frontmatter(content) ⇒ Object



188
189
190
191
192
193
194
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 188

def self.strip_frontmatter(content)
  extraction = Atoms::FrontmatterExtractor.extract(content)
  return content unless extraction[:has_frontmatter]

  frontmatter_lines = extraction[:frontmatter].to_s.lines.count + 2
  ("\n" * frontmatter_lines) + extraction[:body].to_s
end


220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/ace/lint/molecules/markdown_linter.rb', line 220

def self.strip_link_markup(text)
  output = +""
  cursor = 0

  each_markdown_link(text) do |link|
    output << text[cursor...link[:start]]
    output << link[:link_text]
    cursor = link[:end_exclusive]
  end

  output << (text[cursor..] || "")
  output
end