├── test ├── rule_tests │ ├── empty_doc.md │ ├── first_header_good_atx.md │ ├── no_first_line_header_style.rb │ ├── first_header_bad_atx.md │ ├── no_first_line_top_level_header_style.rb │ ├── first_header_good_setext.md │ ├── code_block_fenced_style.rb │ ├── bulletd_list_sublist_style.rb │ ├── code_block_indented_style.rb │ ├── first_header_bad_setext.md │ ├── code_block_consistency_style.rb │ ├── consistent_bullet_styles_dash.md │ ├── consistent_bullet_styles_plus.md │ ├── header_multiple_toplevel.md │ ├── incorrect_header_atx_style.rb │ ├── consistent_bullet_styles_asterisk.md │ ├── headers_good.md │ ├── incorrect_header_setext_style.rb │ ├── no_first_line_header.md │ ├── no_first_line_top_level_header.md │ ├── first_line_top_level_header_atx_style.rb │ ├── first_line_top_level_header_setext_style.rb │ ├── incorrect_bullet_style_dash.md │ ├── incorrect_bullet_style_plus.md │ ├── incorrect_header_atx_closed_style.rb │ ├── headers_good_setext_with_atx_style.rb │ ├── hr_style_dashes_style.rb │ ├── hr_style_long_style.rb │ ├── hr_style_stars_style.rb │ ├── incorrect_bullet_style_asterisk.md │ ├── whitespace_issues.md │ ├── alternate_top_level_header.md │ ├── header_trailing_punctuation_customized_style.rb │ ├── inline_html_style.rb │ ├── long_lines_100_style.rb │ ├── trailing_spaces_br_style.rb │ ├── bulleted_list_2_space_indent_style.rb │ ├── bulleted_list_4_space_indent.md │ ├── fenced_code_without_blank_lines_style.rb │ ├── header_duplicate_content_different_nesting_style.rb │ ├── header_mutliple_h1_no_toplevel.md │ ├── atx_header_spacing.md │ ├── incorrect_bullet_style_dash_style.rb │ ├── incorrect_bullet_style_plus_style.rb │ ├── first_line_top_level_header_atx.md │ ├── hard_tabs_code_blocks_style.rb │ ├── headers_bad.md │ ├── headers_good_setext_with_atx.md │ ├── inconsistent_bullet_indent_same_level.md │ ├── incorrect_bullet_style_asterisk_style.rb │ ├── incorrect_header_setext.md │ ├── ordered_list_item_prefix_ordered_style.rb │ ├── incorrect_header_atx.md │ ├── long_lines_code_style.rb │ ├── mixed_header_types_atx.md │ ├── mixed_header_types_setext.md │ ├── default_test_style.rb │ ├── incorrect_header_atx_closed.md │ ├── mixed_header_types_atx_closed.md │ ├── hard_tabs_code_blocks.md │ ├── alternate_top_level_header_style.rb │ ├── first_line_top_level_header_setext.md │ ├── md013_ignore_long_lines_in_code_block_style.rb │ ├── file_ends_with_single_newline_character_good.md │ ├── file_ends_with_single_newline_character_bad.md │ ├── header_duplicate_content.md │ ├── spaces_after_list_marker_style.rb │ ├── header_duplicate_content_different_nesting.md │ ├── inconsistent_bullet_styles_dash.md │ ├── inconsistent_bullet_styles_plus.md │ ├── inconsistent_bullet_styles_asterisk.md │ ├── code_block_consistency.md │ ├── consecutive_blank_lines.md │ ├── header_duplicate_content_no_different_nesting.md │ ├── bulleted_list_2_space_indent.md │ ├── headers_good_with_issue_numbers.md │ ├── header_trailing_punctuation.md │ ├── ordered_list_item_prefix.md │ ├── ordered_list_item_prefix_ordered.md │ ├── trailing_spaces_br.md │ ├── long_lines.md │ ├── bulleted_list_not_at_beginning_of_line.md │ ├── reversed_link.md │ ├── hr_style_stars.md │ ├── headers_surrounding_space_setext.md │ ├── hr_style_inconsistent.md │ ├── links.md │ ├── hr_style_dashes.md │ ├── hr_style_long.md │ ├── code_block_indented.md │ ├── code_block_fenced.md │ ├── atx_closed_header_spacing.md │ ├── header_trailing_punctuation_customized.md │ ├── headers_surrounding_space_atx.md │ ├── code_block_dollar_fence.md │ ├── bulletd_list_sublist.md │ ├── fix_102_extra_nodes_in_link_text.md │ ├── spaces_inside_codespan_elements.md │ ├── md013_ignore_long_lines_in_code_block.md │ ├── long_lines_100.md │ ├── fenced_code_without_blank_lines.md │ ├── fenced_code_blocks.md │ ├── inline_html.md │ ├── headers_with_spaces_at_the_beginning.md │ ├── blockquote_spaces.md │ ├── spaces_inside_link_text.md │ ├── blockquote_blank_lines.md │ ├── fenced_code_blocks_in_lists.md │ ├── code_block_dollar.md │ ├── fenced_code_with_nesting.md │ ├── spaces_inside_emphasis_markers.md │ ├── lists_without_blank_lines.md │ ├── spaces_after_list_marker.md │ ├── emphasis_instead_of_headers.md │ └── long_lines_code.md ├── fixtures │ ├── default_mdlrc │ ├── unprintable_chars │ │ ├── test2.md │ │ ├── test1.md │ │ └── test3.md │ ├── output │ │ ├── json │ │ │ ├── without_matches.json │ │ │ └── with_matches.json │ │ └── sarif │ │ │ ├── without_matches.sarif │ │ │ └── with_matches.sarif │ ├── mdlrc_disable_rules │ ├── mdlrc_disable_tags │ ├── mdlrc_enable_rules │ ├── mdlrc_enable_tags │ ├── dir_with_md_and_markdown │ │ ├── foo.md │ │ └── bar.markdown │ ├── fake_tty.rb │ ├── my_ruleset.rb │ ├── docs_ruleset_1.rb │ ├── front_matter │ │ ├── jekyll_post.md │ │ └── jekyll_post_2.md │ └── docs_ruleset_2.rb ├── setup_tests.rb ├── test_rules.rb ├── test_ruledocs.rb └── test_cli.rb ├── .mdlrc ├── lib ├── mdl │ ├── styles │ │ ├── all.rb │ │ ├── default.rb │ │ ├── relaxed.rb │ │ └── cirosantilli.rb │ ├── version.rb │ ├── config.rb │ ├── kramdown_parser.rb │ ├── ruleset.rb │ ├── style.rb │ ├── formatters │ │ └── sarif.rb │ ├── cli.rb │ ├── doc.rb │ └── rules.rb └── mdl.rb ├── Gemfile ├── example ├── new_style_example.rb └── .mdlrc_example ├── .mdl_style.rb ├── .gitignore ├── Rakefile ├── tools ├── README.md ├── view_markdown.rb ├── docker │ ├── Dockerfile │ └── README.md └── test_location.rb ├── bin └── mdl ├── .pre-commit-hooks.yaml ├── .github ├── workflows │ ├── dco.yml │ └── ci.yml ├── ISSUE_TEMPLATE │ ├── BUG_TEMPLATE.md │ ├── ENHANCEMENT_REQUEST.md │ ├── RULE_REQUEST.md │ └── DESIGN_PROPOSAL.md └── PULL_REQUEST_TEMPLATE.md ├── MAINTAINERS.md ├── LICENSE.txt ├── .rubocop.yml ├── mdl.gemspec ├── docs ├── creating_styles.md ├── rolling_a_release.md ├── configuration.md └── creating_rules.md ├── README.md ├── CONTRIBUTING.md └── CHANGELOG.md /test/rule_tests/empty_doc.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | style '.mdl_style.rb' 2 | -------------------------------------------------------------------------------- /lib/mdl/styles/all.rb: -------------------------------------------------------------------------------- 1 | all 2 | -------------------------------------------------------------------------------- /test/fixtures/default_mdlrc: -------------------------------------------------------------------------------- 1 | # Blank .mdlrc 2 | -------------------------------------------------------------------------------- /test/fixtures/unprintable_chars/test2.md: -------------------------------------------------------------------------------- 1 | A * B -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /test/fixtures/output/json/without_matches.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /test/rule_tests/first_header_good_atx.md: -------------------------------------------------------------------------------- 1 | # Header 2 | -------------------------------------------------------------------------------- /test/rule_tests/no_first_line_header_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | -------------------------------------------------------------------------------- /test/fixtures/unprintable_chars/test1.md: -------------------------------------------------------------------------------- 1 | A 2 | 3 | * B -------------------------------------------------------------------------------- /test/fixtures/unprintable_chars/test3.md: -------------------------------------------------------------------------------- 1 | A 2 | 3 | B -------------------------------------------------------------------------------- /test/fixtures/mdlrc_disable_rules: -------------------------------------------------------------------------------- 1 | rules "~MD001", "~MD002" 2 | -------------------------------------------------------------------------------- /test/fixtures/mdlrc_disable_tags: -------------------------------------------------------------------------------- 1 | tags "ul", "~indentation" 2 | -------------------------------------------------------------------------------- /test/fixtures/mdlrc_enable_rules: -------------------------------------------------------------------------------- 1 | rules "MD001", "MD002" 2 | -------------------------------------------------------------------------------- /test/rule_tests/first_header_bad_atx.md: -------------------------------------------------------------------------------- 1 | ## Header {MD002} 2 | -------------------------------------------------------------------------------- /test/rule_tests/no_first_line_top_level_header_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | -------------------------------------------------------------------------------- /test/fixtures/mdlrc_enable_tags: -------------------------------------------------------------------------------- 1 | tags "headers", "whitespace" 2 | -------------------------------------------------------------------------------- /test/rule_tests/first_header_good_setext.md: -------------------------------------------------------------------------------- 1 | Header 2 | ====== 3 | -------------------------------------------------------------------------------- /test/fixtures/dir_with_md_and_markdown/foo.md: -------------------------------------------------------------------------------- 1 | ## Second Header First 2 | -------------------------------------------------------------------------------- /example/new_style_example.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :line_length => 500 3 | -------------------------------------------------------------------------------- /test/fixtures/dir_with_md_and_markdown/bar.markdown: -------------------------------------------------------------------------------- 1 | ## Second Header First 2 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_fenced_style.rb: -------------------------------------------------------------------------------- 1 | rule 'MD046', :style => :fenced 2 | -------------------------------------------------------------------------------- /lib/mdl/version.rb: -------------------------------------------------------------------------------- 1 | module MarkdownLint 2 | VERSION = '0.13.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /test/rule_tests/bulletd_list_sublist_style.rb: -------------------------------------------------------------------------------- 1 | rule 'MD004', :style => :sublist 2 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_indented_style.rb: -------------------------------------------------------------------------------- 1 | rule 'MD046', :style => :indented 2 | -------------------------------------------------------------------------------- /test/rule_tests/first_header_bad_setext.md: -------------------------------------------------------------------------------- 1 | Header {MD002} 2 | -------------- 3 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_consistency_style.rb: -------------------------------------------------------------------------------- 1 | rule 'MD046', :style => :consistent 2 | -------------------------------------------------------------------------------- /test/rule_tests/consistent_bullet_styles_dash.md: -------------------------------------------------------------------------------- 1 | - Item 2 | - Item 3 | - Item 4 | -------------------------------------------------------------------------------- /test/rule_tests/consistent_bullet_styles_plus.md: -------------------------------------------------------------------------------- 1 | + Item 2 | + Item 3 | + Item 4 | -------------------------------------------------------------------------------- /test/rule_tests/header_multiple_toplevel.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | # Heading 2 {MD025} 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_atx_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD003', :style => :atx 3 | -------------------------------------------------------------------------------- /test/rule_tests/consistent_bullet_styles_asterisk.md: -------------------------------------------------------------------------------- 1 | * Item 2 | * Item 3 | * Item 4 | -------------------------------------------------------------------------------- /test/rule_tests/headers_good.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | ## Heading 2 4 | 5 | ## Heading 3 6 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_setext_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD003', :style => :setext 3 | -------------------------------------------------------------------------------- /test/rule_tests/no_first_line_header.md: -------------------------------------------------------------------------------- 1 | This is a file without a top level header {MD041} 2 | -------------------------------------------------------------------------------- /test/rule_tests/no_first_line_top_level_header.md: -------------------------------------------------------------------------------- 1 | ## Second level header {MD041} {MD002} 2 | -------------------------------------------------------------------------------- /example/.mdlrc_example: -------------------------------------------------------------------------------- 1 | style "#{File.dirname(__FILE__)}/{your_markdown_rule_file_path}.rb" 2 | -------------------------------------------------------------------------------- /test/rule_tests/first_line_top_level_header_atx_style.rb: -------------------------------------------------------------------------------- 1 | # Only test MD041 here 2 | rule 'MD041' 3 | -------------------------------------------------------------------------------- /test/rule_tests/first_line_top_level_header_setext_style.rb: -------------------------------------------------------------------------------- 1 | # Only test MD041 here 2 | rule 'MD041' 3 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_dash.md: -------------------------------------------------------------------------------- 1 | * Item {MD004} 2 | - Item 3 | + Item {MD004} 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_plus.md: -------------------------------------------------------------------------------- 1 | * Item {MD004} 2 | - Item {MD004} 3 | + Item 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_atx_closed_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD003', :style => :atx_closed 3 | -------------------------------------------------------------------------------- /test/rule_tests/headers_good_setext_with_atx_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD003', :style => :setext_with_atx 3 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_dashes_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD035', :style => '---' 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_long_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD035', :style => '_____' 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_stars_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD035', :style => '***' 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_asterisk.md: -------------------------------------------------------------------------------- 1 | * Item 2 | - Item {MD004} 3 | + Item {MD004} 4 | -------------------------------------------------------------------------------- /test/rule_tests/whitespace_issues.md: -------------------------------------------------------------------------------- 1 | Some text {MD009} 2 | Some more text {MD010} 3 | Some more text 4 | -------------------------------------------------------------------------------- /test/rule_tests/alternate_top_level_header.md: -------------------------------------------------------------------------------- 1 | ## A level 2 top level header 2 | 3 | ## Another one {MD025} 4 | -------------------------------------------------------------------------------- /test/rule_tests/header_trailing_punctuation_customized_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD026', :punctuation => '.,;:!' 3 | -------------------------------------------------------------------------------- /test/rule_tests/inline_html_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | exclude_rule 'MD046' 3 | rule 'MD033', :allowed_elements => 'br' 4 | -------------------------------------------------------------------------------- /test/rule_tests/long_lines_100_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :line_length => 100 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/trailing_spaces_br_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD009', :br_spaces => 2 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/bulleted_list_2_space_indent_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD007', :indent => 4 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/bulleted_list_4_space_indent.md: -------------------------------------------------------------------------------- 1 | * Test X 2 | * Test Y {MD007} 3 | * Test Z {MD007} 4 | -------------------------------------------------------------------------------- /test/rule_tests/fenced_code_without_blank_lines_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | exclude_rule 'MD040' 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/header_duplicate_content_different_nesting_style.rb: -------------------------------------------------------------------------------- 1 | rule 'MD024', :allow_different_nesting => true 2 | -------------------------------------------------------------------------------- /test/rule_tests/header_mutliple_h1_no_toplevel.md: -------------------------------------------------------------------------------- 1 | Some introductory text 2 | 3 | # Heading 1 4 | 5 | # Heading 2 6 | -------------------------------------------------------------------------------- /test/rule_tests/atx_header_spacing.md: -------------------------------------------------------------------------------- 1 | #Header 1 {MD018} 2 | 3 | ## Header 2 {MD019} 4 | 5 | ## Header 3 {MD019} 6 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_dash_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD004', :style => :dash 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_plus_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD004', :style => :plus 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/first_line_top_level_header_atx.md: -------------------------------------------------------------------------------- 1 | # First line is a top level header 2 | 3 | This shouldn't trigger MD041 4 | -------------------------------------------------------------------------------- /test/rule_tests/hard_tabs_code_blocks_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD010', :ignore_code_blocks => true 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/headers_bad.md: -------------------------------------------------------------------------------- 1 | # Header 2 | 3 | ### Header 3 {MD001} 4 | 5 | ## Header 2 6 | 7 | #### Header 4 {MD001} 8 | -------------------------------------------------------------------------------- /test/rule_tests/headers_good_setext_with_atx.md: -------------------------------------------------------------------------------- 1 | Header 1 2 | ======== 3 | 4 | Header 2 5 | -------- 6 | 7 | ### Header 3 8 | -------------------------------------------------------------------------------- /test/rule_tests/inconsistent_bullet_indent_same_level.md: -------------------------------------------------------------------------------- 1 | * Item 2 | * Item {MD007} 3 | * Item {MD005} 4 | * Item 5 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_bullet_style_asterisk_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD004', :style => :asterisk 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_setext.md: -------------------------------------------------------------------------------- 1 | # Header 1 {MD003} # 2 | 3 | ## Header 2 {MD003} 4 | 5 | Header 3 6 | -------- 7 | -------------------------------------------------------------------------------- /test/rule_tests/ordered_list_item_prefix_ordered_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD029', :style => :ordered 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_atx.md: -------------------------------------------------------------------------------- 1 | # Header 1 {MD003} # 2 | 3 | ## Header 2 4 | 5 | Header 3 {MD003} 6 | ---------------- 7 | -------------------------------------------------------------------------------- /test/rule_tests/long_lines_code_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :code_blocks => false, :tables => false 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /test/rule_tests/mixed_header_types_atx.md: -------------------------------------------------------------------------------- 1 | # Header 2 | 3 | ## Header 2 {MD003} ## 4 | 5 | Header 3 {MD003} 6 | ---------------- 7 | -------------------------------------------------------------------------------- /test/rule_tests/mixed_header_types_setext.md: -------------------------------------------------------------------------------- 1 | Header 1 2 | ======== 3 | 4 | ## Header 2 {MD003} 5 | 6 | ## Header 3 {MD003} ## 7 | -------------------------------------------------------------------------------- /test/setup_tests.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.setup 3 | 4 | require_relative '../lib/mdl' 5 | require 'minitest/autorun' 6 | -------------------------------------------------------------------------------- /test/rule_tests/default_test_style.rb: -------------------------------------------------------------------------------- 1 | # Default style file for rule tests 2 | all 3 | 4 | exclude_rule 'MD041' 5 | # exclude_rule "MD046" 6 | -------------------------------------------------------------------------------- /test/rule_tests/incorrect_header_atx_closed.md: -------------------------------------------------------------------------------- 1 | # Header 1 # 2 | 3 | ## Header 2 {MD003} 4 | 5 | Header 3 {MD003} 6 | ---------------- 7 | -------------------------------------------------------------------------------- /test/rule_tests/mixed_header_types_atx_closed.md: -------------------------------------------------------------------------------- 1 | # Header 1 # 2 | 3 | ## Header 2 {MD003} 4 | 5 | Header 3 {MD003} 6 | ---------------- 7 | -------------------------------------------------------------------------------- /test/rule_tests/hard_tabs_code_blocks.md: -------------------------------------------------------------------------------- 1 | ```makefile 2 | text after hard tab 3 | ``` 4 | 5 | Text before hard tab text after hard tab {MD010} 6 | -------------------------------------------------------------------------------- /test/rule_tests/alternate_top_level_header_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD002', :level => 2 3 | rule 'MD025', :level => 2 4 | rule 'MD041', :level => 2 5 | -------------------------------------------------------------------------------- /test/rule_tests/first_line_top_level_header_setext.md: -------------------------------------------------------------------------------- 1 | First line top level header 2 | =========================== 3 | 4 | This shouldn't trigger MD041 5 | -------------------------------------------------------------------------------- /.mdl_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | # our changelog does this, by design 3 | exclude_rule 'MD024' 4 | # default in next version, remove then 5 | rule 'MD007', :indent => 3 6 | -------------------------------------------------------------------------------- /test/rule_tests/md013_ignore_long_lines_in_code_block_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :ignore_code_blocks => true, :tables => false 3 | exclude_rule 'MD041' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Bundler/Ruby 2 | vendor/ 3 | .bundle 4 | Gemfile.lock 5 | spec/reports 6 | tmp 7 | *.gem 8 | 9 | # Vim 10 | *.swp 11 | 12 | # Mac 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /test/rule_tests/file_ends_with_single_newline_character_good.md: -------------------------------------------------------------------------------- 1 | # File ending with a single newline character 2 | 3 | This file ends with a single newline character 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | task :default => :test 5 | 6 | Rake::TestTask.new do |t| 7 | t.pattern = 'test/test_*.rb' 8 | end 9 | -------------------------------------------------------------------------------- /test/rule_tests/file_ends_with_single_newline_character_bad.md: -------------------------------------------------------------------------------- 1 | # File not ending with a single newline character 2 | 3 | This file does not end with a single newline character{MD047} -------------------------------------------------------------------------------- /test/fixtures/fake_tty.rb: -------------------------------------------------------------------------------- 1 | # A hacky 'ruleset' file which forces an externally run 2 | # mdl instance to believe it's in a TTY, used for testing. 3 | def $stdout.tty? 4 | true 5 | end 6 | -------------------------------------------------------------------------------- /test/rule_tests/header_duplicate_content.md: -------------------------------------------------------------------------------- 1 | # Header 1 2 | 3 | ## Header 2 4 | 5 | ## Header 1 6 | 7 | ### Header 2 8 | 9 | ## Header 3 10 | 11 | {MD024:5} {MD024:7} 12 | -------------------------------------------------------------------------------- /test/rule_tests/spaces_after_list_marker_style.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD007', :indent => 4 3 | rule 'MD030', :ul_multi => 3, :ol_multi => 2 4 | exclude_rule 'MD041' 5 | exclude_rule 'MD046' 6 | -------------------------------------------------------------------------------- /test/fixtures/my_ruleset.rb: -------------------------------------------------------------------------------- 1 | rule 'MY001', 'Documents must start with Hello World' do 2 | tags :opinionated 3 | check do |doc| 4 | [1] if doc.lines[0] != 'Hello World' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/rule_tests/header_duplicate_content_different_nesting.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 2.0.0 4 | 5 | ### Bug fixes 6 | 7 | ### Features 8 | 9 | ## 1.0.0 10 | 11 | ### Bug fixes 12 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | This directory contains a few scripts that, while not part of markdownlint 4 | directly, can be useful for inspecting markdown documents and debugging 5 | issues. 6 | -------------------------------------------------------------------------------- /lib/mdl/styles/default.rb: -------------------------------------------------------------------------------- 1 | all 2 | 3 | exclude_rule 'fenced-code-language' # Fenced code blocks should have a language 4 | exclude_rule 'first-line-h1' # First line in file should be a top level header 5 | -------------------------------------------------------------------------------- /test/rule_tests/inconsistent_bullet_styles_dash.md: -------------------------------------------------------------------------------- 1 | - Item 2 | * Item {MD004} 3 | + Item {MD004} 4 | - Item 5 | 6 | > - Item 7 | > * Item {MD004} 8 | > + Item {MD004} 9 | > - Item 10 | -------------------------------------------------------------------------------- /test/rule_tests/inconsistent_bullet_styles_plus.md: -------------------------------------------------------------------------------- 1 | + Item 2 | * Item {MD004} 3 | - Item {MD004} 4 | + Item 5 | 6 | > + Item 7 | > * Item {MD004} 8 | > - Item {MD004} 9 | > + Item 10 | -------------------------------------------------------------------------------- /test/rule_tests/inconsistent_bullet_styles_asterisk.md: -------------------------------------------------------------------------------- 1 | * Item 2 | + Item {MD004} 3 | - Item {MD004} 4 | * Item 5 | 6 | > * Item 7 | > + Item {MD004} 8 | > - Item {MD004} 9 | > * Item 10 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_consistency.md: -------------------------------------------------------------------------------- 1 | This is text. 2 | 3 | This is a 4 | code block. 5 | 6 | And here is more text 7 | 8 | ``` 9 | and here is a different {MD046:8} 10 | code block 11 | ``` 12 | -------------------------------------------------------------------------------- /test/rule_tests/consecutive_blank_lines.md: -------------------------------------------------------------------------------- 1 | Some text 2 | 3 | 4 | Some text {MD012:3} 5 | 6 | ```fenced 7 | This is a code block 8 | 9 | 10 | with two blank lines in it 11 | ``` 12 | 13 | Some more text 14 | -------------------------------------------------------------------------------- /test/rule_tests/header_duplicate_content_no_different_nesting.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 2.0.0 4 | 5 | ### Bug fixes 6 | 7 | ### Features 8 | 9 | ## 1.0.0 10 | 11 | ### Bug fixes 12 | 13 | {MD024:11} 14 | -------------------------------------------------------------------------------- /lib/mdl/config.rb: -------------------------------------------------------------------------------- 1 | require 'mixlib/config' 2 | 3 | module MarkdownLint 4 | # our Mixlib::Config class 5 | module Config 6 | extend Mixlib::Config 7 | 8 | default :style, 'default' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/rule_tests/bulleted_list_2_space_indent.md: -------------------------------------------------------------------------------- 1 | This is a document where the lists are indented by 2 spaces, but the style is 2 | set to 4 space indents for lists: 3 | 4 | * Test X 5 | * Test Y {MD007} 6 | * Test Z {MD007} 7 | -------------------------------------------------------------------------------- /test/rule_tests/headers_good_with_issue_numbers.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | ## Heading 2 4 | 5 | See the following issues: 6 | 7 | * #1234 8 | * #5678 (and related) 9 | * #5678 10 | * #9101 11 | 12 | ## Heading 3 13 | -------------------------------------------------------------------------------- /test/rule_tests/header_trailing_punctuation.md: -------------------------------------------------------------------------------- 1 | # Heading 1 {MD026}. 2 | 3 | ## Heading 2 {MD026}, 4 | 5 | ## Heading 3 {MD026}! 6 | 7 | ## Heading 4 {MD026}: 8 | 9 | ## Heading 5 {MD026}; 10 | 11 | ## Heading 6 {MD026}? 12 | -------------------------------------------------------------------------------- /test/rule_tests/ordered_list_item_prefix.md: -------------------------------------------------------------------------------- 1 | Good list: 2 | 3 | 1. Do this. 4 | 1. Do that. 5 | 1. ??? 6 | 1. Profit! 7 | 8 | Bad list: 9 | 10 | 1. Do this. 11 | 2. Do nothing. {MD029} 12 | 3. ??? {MD029} 13 | 4. Failed! {MD029} 14 | -------------------------------------------------------------------------------- /test/rule_tests/ordered_list_item_prefix_ordered.md: -------------------------------------------------------------------------------- 1 | Good list: 2 | 3 | 1. Do this. 4 | 2. Do that. 5 | 3. ??? 6 | 4. Profit! 7 | 8 | Bad list: 9 | 10 | 1. Do this. 11 | 1. Do nothing. {MD029} 12 | 1. ??? {MD029} 13 | 1. Failed! {MD029} 14 | -------------------------------------------------------------------------------- /test/rule_tests/trailing_spaces_br.md: -------------------------------------------------------------------------------- 1 | This line has a single trailing space {MD009} 2 | This line has two trailing spaces and should be allowed 3 | This line has three trailing spaces {MD009} 4 | This line has four trailing spaces {MD009} 5 | -------------------------------------------------------------------------------- /test/rule_tests/long_lines.md: -------------------------------------------------------------------------------- 1 | This is a very very very very very very very very very very very very very very long line {MD013} 2 | 3 | This line however, while very long, doesn't have whitespace after the 80th columnwhichallowsforURLsandotherlongthings. 4 | -------------------------------------------------------------------------------- /bin/mdl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | require 'mdl' 4 | rescue LoadError 5 | # For running in development without bundler 6 | $LOAD_PATH << File.expand_path('../lib', File.dirname(__FILE__)) 7 | require 'mdl' 8 | end 9 | 10 | MarkdownLint.run 11 | -------------------------------------------------------------------------------- /test/rule_tests/bulleted_list_not_at_beginning_of_line.md: -------------------------------------------------------------------------------- 1 | Some text 2 | 3 | * Item {MD006} 4 | * Item 5 | * Item 6 | * Item 7 | * Item 8 | * Item 9 | * Item 10 | 11 | Some more text 12 | 13 | * Item {MD006} 14 | * Item 15 | -------------------------------------------------------------------------------- /test/rule_tests/reversed_link.md: -------------------------------------------------------------------------------- 1 | Go to (this website)[http://www.example.com] {MD011} {MD034} 2 | 3 | However, this shouldn't trigger inside code blocks: 4 | 5 | ```fenced 6 | myObj.getFiles("test")[0] 7 | ``` 8 | 9 | Nor inline code: `myobj.getFiles("test")[0]` 10 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_stars.md: -------------------------------------------------------------------------------- 1 | *** 2 | 3 | * * * 4 | 5 | ***** 6 | 7 | --- 8 | 9 | - - - 10 | 11 | ----- 12 | 13 | ___ 14 | 15 | _ _ _ 16 | 17 | _____ 18 | 19 | *** 20 | 21 | {MD035:3} {MD035:5} {MD035:7} {MD035:9} {MD035:11} {MD035:13} {MD035:15} 22 | {MD035:17} 23 | -------------------------------------------------------------------------------- /test/rule_tests/headers_surrounding_space_setext.md: -------------------------------------------------------------------------------- 1 | Header 1 2 | ======== 3 | 4 | Header 2 {MD022} 5 | ---------------- 6 | Some text 7 | Header 3 {MD022} 8 | ================ 9 | Some text 10 | Header 4 {MD022} 11 | ================ 12 | Some text 13 | 14 | Header 5 15 | -------- 16 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_inconsistent.md: -------------------------------------------------------------------------------- 1 | *** 2 | 3 | * * * 4 | 5 | ***** 6 | 7 | --- 8 | 9 | - - - 10 | 11 | ----- 12 | 13 | ___ 14 | 15 | _ _ _ 16 | 17 | _____ 18 | 19 | *** 20 | 21 | {MD035:3} {MD035:5} {MD035:7} {MD035:9} {MD035:11} {MD035:13} {MD035:15} 22 | {MD035:17} 23 | -------------------------------------------------------------------------------- /test/rule_tests/links.md: -------------------------------------------------------------------------------- 1 | # Link test 2 | 3 | For more information, please see the 4 | following page: http://www.example.com/ {MD034} 5 | which will tell you all you want to know. 6 | 7 | http://www.google.com/ {MD034} 8 | 9 | This link should be fine: 10 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_dashes.md: -------------------------------------------------------------------------------- 1 | *** 2 | 3 | * * * 4 | 5 | ***** 6 | 7 | --- 8 | 9 | - - - 10 | 11 | ----- 12 | 13 | ___ 14 | 15 | _ _ _ 16 | 17 | _____ 18 | 19 | *** 20 | 21 | {MD035:1} {MD035:3} {MD035:5} {MD035:9} {MD035:11} {MD035:13} {MD035:15} 22 | {MD035:17} {MD035:19} 23 | -------------------------------------------------------------------------------- /test/rule_tests/hr_style_long.md: -------------------------------------------------------------------------------- 1 | *** 2 | 3 | * * * 4 | 5 | ***** 6 | 7 | --- 8 | 9 | - - - 10 | 11 | ----- 12 | 13 | ___ 14 | 15 | _ _ _ 16 | 17 | _____ 18 | 19 | *** 20 | 21 | {MD035:1} {MD035:3} {MD035:5} {MD035:7} {MD035:9} {MD035:11} {MD035:13} 22 | {MD035:15} {MD035:19} 23 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_indented.md: -------------------------------------------------------------------------------- 1 | This is text. 2 | 3 | This is a 4 | code block. 5 | 6 | And here is more text 7 | 8 | ``` 9 | This is {MD046:8} also a code block. 10 | ``` 11 | 12 | But we'll do another: 13 | 14 | And this 15 | will. 16 | 17 | Final text is here 18 | -------------------------------------------------------------------------------- /test/fixtures/output/json/with_matches.json: -------------------------------------------------------------------------------- 1 | [{"filename":"(stdin)","line":1,"rule":"MD002","aliases":["first-header-h1"],"description":"First header should be a top level header","docs":"https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header"}] 2 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_fenced.md: -------------------------------------------------------------------------------- 1 | This is text. 2 | 3 | This is a {MD046} 4 | code block. 5 | 6 | And here is more text 7 | 8 | ``` 9 | This is a code block that won't trigger. 10 | ``` 11 | 12 | But we'll do another: 13 | 14 | And this {MD046} 15 | will. 16 | 17 | Final text is here 18 | -------------------------------------------------------------------------------- /test/rule_tests/atx_closed_header_spacing.md: -------------------------------------------------------------------------------- 1 | #Header 1 {MD020} # 2 | 3 | ## Header 2 {MD020}## 4 | 5 | ##Header 3 {MD020}## 6 | 7 | ## Header 4 {MD021} ## 8 | 9 | ## Header 5 {MD021} ## 10 | 11 | ## Header 6 {MD021} ## 12 | 13 | ## Header 7 {MD021} ## 14 | 15 | ## Header 8 \# 16 | 17 | ## Header 9 \# 18 | -------------------------------------------------------------------------------- /test/rule_tests/header_trailing_punctuation_customized.md: -------------------------------------------------------------------------------- 1 | # Heading 1 {MD026}. 2 | 3 | ## Heading 2 {MD026}, 4 | 5 | ## Heading 3 {MD026}! 6 | 7 | ## Heading 4 {MD026}: 8 | 9 | ## Heading 5 {MD026}; 10 | 11 | ## Heading 6? 12 | 13 | The rule has been customized to allow question marks while disallowing 14 | everything else. 15 | -------------------------------------------------------------------------------- /test/rule_tests/headers_surrounding_space_atx.md: -------------------------------------------------------------------------------- 1 | # Header 1 2 | 3 | ## Header 2 {MD022} 4 | Some text 5 | ## Header 3 {MD022} 6 | Some text 7 | ## Header 4 {MD022} 8 | 9 | ## Header 5 10 | 11 | * This shouldn't trigger MD022, but did because of some bug where we tried to 12 | #catch headers that kramdown didn't parse correctly. 13 | -------------------------------------------------------------------------------- /test/fixtures/output/sarif/without_matches.sarif: -------------------------------------------------------------------------------- 1 | {"$schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json","version":"2.1.0","runs":[{"tool":{"driver":{"name":"Markdown lint","version":"<%= MarkdownLint::VERSION %>","informationUri":"https://github.com/markdownlint/markdownlint","rules":[]}},"results":[]}]} 2 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_dollar_fence.md: -------------------------------------------------------------------------------- 1 | # header 2 | 3 | ```fence 4 | $ code 5 | ``` 6 | 7 | text 8 | 9 | ```fence 10 | $ code 11 | ``` 12 | 13 | text 14 | 15 | ```fence 16 | $ code 17 | $ code 18 | ``` 19 | 20 | text 21 | 22 | ```fence 23 | $ code 24 | $ code 25 | ``` 26 | 27 | text 28 | 29 | {MD014:3} {MD014:9} {MD014:15} {MD014:22} 30 | -------------------------------------------------------------------------------- /test/rule_tests/bulletd_list_sublist.md: -------------------------------------------------------------------------------- 1 | This is a document where the lists are consisent style per-sublist 2 | 3 | * stuff 4 | * other stuff 5 | - indented stuff 6 | - more indented stuff 7 | + thing {MD004} 8 | - stuff 9 | + woah 10 | + this 11 | + is 12 | + ok but... 13 | - not this {MD004} 14 | + but this 15 | * thing 16 | -------------------------------------------------------------------------------- /test/rule_tests/fix_102_extra_nodes_in_link_text.md: -------------------------------------------------------------------------------- 1 | [test _test_ test](www.test.com) 2 | [test `test` test](www.test.com) 3 | [test *test* test](www.test.com) 4 | [test *test* *test* test](www.test.com) 5 | [test *test* *test* *test* test](www.test.com) 6 | [test **test** test](www.test.com) 7 | [test __test__ test](www.test.com) 8 | [this should not raise](www.shouldnotraise.com) 9 | -------------------------------------------------------------------------------- /test/rule_tests/spaces_inside_codespan_elements.md: -------------------------------------------------------------------------------- 1 | `normal codespan element` 2 | 3 | `codespan element with space inside right ` {MD038} 4 | 5 | We SHOULD have the following two tests marked with MD038 failurs 6 | ` codespan element with space inside left` 7 | ` codespan element with spaces inside ` but 8 | kramdown doesn't see that as a codespans so we can't detect them anymore. 9 | -------------------------------------------------------------------------------- /tools/view_markdown.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Quick script for viewing getting at kramdown's and markdownlint's view of a 3 | # markdown file 4 | require 'mdl/doc' 5 | require 'pry' 6 | 7 | doc = MarkdownLint::Doc.new_from_file(ARGV[0]) 8 | children = doc.parsed.root.children 9 | 10 | # rubocop:disable Lint/Debugger 11 | binding.pry 12 | # rubocop:enable Lint/Debugger 13 | -------------------------------------------------------------------------------- /test/fixtures/docs_ruleset_1.rb: -------------------------------------------------------------------------------- 1 | docs 'https://example.com/static-docs' 2 | 3 | rule 'MY002', 'Documents must start with A' do 4 | tags :opinionated 5 | check do |doc| 6 | [1] if doc.lines[0] != 'A' 7 | end 8 | end 9 | 10 | rule 'MY003', 'Documents must start with B' do 11 | tags :opinionated 12 | docs 'https://example.com/override-docs' 13 | check do |doc| 14 | [1] if doc.lines[0] != 'B' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/rule_tests/md013_ignore_long_lines_in_code_block.md: -------------------------------------------------------------------------------- 1 | This is a short line. 2 | 3 | This is a very very very very very very very very very very very very very very very very very very very very long line. {MD013} 4 | 5 | This is a short line. 6 | 7 | ```text 8 | Here is a short line in a code block. 9 | Here is a very very very very very very very very very very very very very very very very very very very long line in a code block. 10 | ``` 11 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: markdownlint 2 | name: Markdownlint 3 | description: Run markdownlint on your Markdown files 4 | entry: mdl 5 | language: ruby 6 | files: \.(md|mdown|markdown)$ 7 | 8 | - id: markdownlint_docker 9 | name: Markdownlint Docker 10 | description: Run markdown lint on your Markdown files using the project docker image 11 | language: docker_image 12 | files: \.(md|mdown|markdown)$ 13 | entry: markdownlint/markdownlint 14 | -------------------------------------------------------------------------------- /test/rule_tests/long_lines_100.md: -------------------------------------------------------------------------------- 1 | This is a very very very very very very very very long line over 80 chars but less than 100 2 | 3 | This is a very very very very very very very very very very long line over 80 chars, and also over 100. {MD013} 4 | 5 | This is a very very very very very very very very very long line that is exactly 100 characters long 6 | 7 | This line however, while very long, doesn't have whitespace after the 100th columnwhichallowsforURLsandotherlongthings. 8 | -------------------------------------------------------------------------------- /test/rule_tests/fenced_code_without_blank_lines.md: -------------------------------------------------------------------------------- 1 | ``` 2 | code at start of file 3 | ``` 4 | 5 | text 6 | 7 | ```ruby 8 | code 9 | ``` 10 | 11 | text 12 | ``` {MD031} 13 | code 14 | ``` {MD031} 15 | text 16 | 17 | ``` 18 | code 19 | ``` {MD031} 20 | text 21 | 22 | text 23 | ``` {MD031} 24 | code 25 | ``` 26 | 27 | text 28 | 29 | ```js 30 | code 31 | code 32 | code 33 | ``` 34 | 35 | ```html 36 | ``` 37 | 38 | text 39 | 40 | ``` 41 | code at end of file without newline 42 | ```{MD047} -------------------------------------------------------------------------------- /.github/workflows/dco.yml: -------------------------------------------------------------------------------- 1 | name: DCO Check 2 | on: [pull_request] 3 | 4 | jobs: 5 | dco_check_job: 6 | runs-on: ubuntu-latest 7 | name: DCO Check 8 | steps: 9 | - name: Get PR Commits 10 | uses: tim-actions/get-pr-commits@master 11 | id: 'get-pr-commits' 12 | with: 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | - name: DCO Check 15 | uses: tim-actions/dco@master 16 | with: 17 | commits: ${{ steps.get-pr-commits.outputs.commits }} 18 | -------------------------------------------------------------------------------- /lib/mdl/styles/relaxed.rb: -------------------------------------------------------------------------------- 1 | all 2 | exclude_tag :whitespace 3 | exclude_tag :line_length 4 | 5 | exclude_rule 'MD006' # Lists at beginning of line 6 | exclude_rule 'MD007' # List indentation 7 | exclude_rule 'MD033' # Inline HTML 8 | exclude_rule 'MD034' # Bare URL used 9 | exclude_rule 'MD040' # Fenced code blocks should have a language specified 10 | exclude_rule 'MD041' # First line in file should be a top level header 11 | exclude_rule 'MD047' # File should end with a single newline character 12 | -------------------------------------------------------------------------------- /test/fixtures/front_matter/jekyll_post.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: Hello World! 4 | category: Meta 5 | tags: 6 | - tag 7 | - another tag 8 | - one more tag 9 | url: http://example.com 10 | excerpt: Hello World! Vestibulum imperdiet adipiscing arcu, quis aliquam dolor condimentum dapibus. Aliquam fermentum leo aliquet quam volutpat et molestie mauris mattis. Suspendisse semper consequat velit in suscipit. 11 | --- 12 | # header1 13 | 14 | This is just a sample post. 15 | 16 | ### offending header3 17 | -------------------------------------------------------------------------------- /test/fixtures/front_matter/jekyll_post_2.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: Hello World! 4 | category: Meta 5 | tags: 6 | - tag 7 | - another tag 8 | - one more tag 9 | url: http://example.com 10 | excerpt: Hello World! Vestibulum imperdiet adipiscing arcu, quis aliquam dolor condimentum dapibus. Aliquam fermentum leo aliquet quam volutpat et molestie mauris mattis. Suspendisse semper consequat velit in suscipit. 11 | --- 12 | 13 | # header1 14 | 15 | This is just a sample post. 16 | 17 | ### offending header3 18 | -------------------------------------------------------------------------------- /test/rule_tests/fenced_code_blocks.md: -------------------------------------------------------------------------------- 1 | This is a GFM-style fenced code block: 2 | 3 | ``` bash 4 | #!/bin/bash 5 | 6 | # Print something to stdout: 7 | echo "Hello" 8 | echo "World" 9 | ``` 10 | 11 | This is a kramdown-style fenced code block: 12 | 13 | ~~~ bash 14 | #!/bin/bash 15 | 16 | # Print something to stdout: 17 | echo "Hello" 18 | echo "World" 19 | ~~~ 20 | 21 | None of the above should trigger any heading related rules. 22 | 23 | ``` 24 | Code block without a language specifier 25 | ``` 26 | 27 | {MD040:23} 28 | -------------------------------------------------------------------------------- /test/rule_tests/inline_html.md: -------------------------------------------------------------------------------- 1 | # Regular header 2 | 3 |

Inline HTML Header {MD033}

4 | 5 |

More inline HTML {MD033} 6 | but this time on multiple lines 7 |

8 | 9 |

This shouldn't trigger as it's inside a code block

10 | 11 | ```text 12 |

Neither should this as it's also in a code block

13 | ``` 14 | 15 | The rule has been customized to allow some elements while disallowing 16 | everything else. 17 | 18 | Test case for the line break element
19 | present on `allowed_elements` and it should be permitted. 20 | -------------------------------------------------------------------------------- /test/rule_tests/headers_with_spaces_at_the_beginning.md: -------------------------------------------------------------------------------- 1 | Some text 2 | 3 | # Header 1 {MD023} 4 | 5 | Setext style fully indented {MD023} 6 | =================================== 7 | 8 | Setext style title only indented {MD023} 9 | ========================================= 10 | 11 | * Test situations in which MD023 shouldn't be triggered. 12 | 13 | ```rb 14 | # This shouldn't trigger MD023 as it is a code comment. 15 | foo = "And here is some code" 16 | ``` 17 | 18 | * This is another case where MD023 shouldn't be triggered 19 | # Test 20 | # Test 21 | -------------------------------------------------------------------------------- /test/rule_tests/blockquote_spaces.md: -------------------------------------------------------------------------------- 1 | Some text 2 | 3 | > Hello world 4 | > Foo {MD027} 5 | > Bar {MD027} 6 | 7 | This tests other things embedded in the blockquote: 8 | 9 | - foo 10 | 11 | > *Hello world* 12 | > *foo* {MD027} 13 | > **bar** {MD027} 14 | > "Baz" {MD027} 15 | > `qux` {MD027} 16 | > *foo* more text 17 | > **bar** more text 18 | > 'baz' more text 19 | > `qux` more text 20 | > [link](example.com) to site 21 | > [link](#link) {MD027} 22 | > 23 | > - foo 24 | 25 | Test the first line being indented too much: 26 | 27 | > Foo {MD027} 28 | > Bar {MD027} 29 | > Baz 30 | -------------------------------------------------------------------------------- /lib/mdl/styles/cirosantilli.rb: -------------------------------------------------------------------------------- 1 | # Enforce the style guide at https://cirosantilli.com/markdown-style-guide 2 | all 3 | rule 'MD003', :style => :atx 4 | rule 'MD004', :style => :dash 5 | rule 'MD007', :indent => 4 6 | rule 'MD030', :ul_multi => 3, :ol_multi => 2 7 | rule 'MD035', :style => '---' 8 | 9 | # Inline HTML - this isn't forbidden by the style guide, and raw HTML use is 10 | # explicitly mentioned in the 'email automatic links' section. 11 | exclude_rule 'MD033' 12 | 13 | # File should end with a single newline character 14 | # this isn't forbidden by the style guide 15 | exclude_rule 'MD047' 16 | -------------------------------------------------------------------------------- /test/rule_tests/spaces_inside_link_text.md: -------------------------------------------------------------------------------- 1 | [](http://bar/) 2 | 3 | [foo](http://bar/) 4 | 5 | ["foo"](http:/bar/) 6 | 7 | [`foo`](http://bar/) 8 | 9 | [*foo*](http://bar/) 10 | 11 | [**foo**](http://bar/) 12 | 13 | [foo "bar"](http:/baz/) 14 | 15 | [foo ](http://bar/) {MD039} 16 | 17 | [ foo](http://bar/) {MD039} 18 | 19 | [ foo ](http://bar/) {MD039} 20 | 21 | [ "foo" ](http://bar/) {MD039} 22 | 23 | [ `foo` ](http://bar/) {MD039} 24 | 25 | [ *foo* ](http://bar/) {MD039} 26 | 27 | The following shouldn't break anything: 28 | [![Screenshot.png](/images/Screenshot.png)](/images/Screenshot.png) 29 | -------------------------------------------------------------------------------- /test/rule_tests/blockquote_blank_lines.md: -------------------------------------------------------------------------------- 1 | Some text 2 | 3 | > a quote 4 | > same quote 5 | 6 | > blank line above me 7 | 8 | 9 | > two blank lines above me 10 | 11 | > space above me 12 | 13 | * List with embedded blockquote 14 | 15 | > Test 16 | > Test 17 | 18 | > Test 19 | 20 | * Item 2 21 | 22 | > Test. The blank line below should _not_ trigger MD028 as one blockquote is 23 | > inside the list, and the other is outside it. 24 | 25 | > Test 26 | 27 | Expected errors: 28 | 29 | {MD028:5} {MD028:8} {MD028:10} {MD028:17} 30 | {MD009:10} (trailing space is intentional) 31 | {MD012:8} (multiple blank lines are intentional) 32 | -------------------------------------------------------------------------------- /tools/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.16.2 2 | # Standard library gems used by markdownlint, such as 'etc' and 'json', are 3 | # not installed by default in Alpine Linux distros, since the current policy 4 | # is to have small packages with extra functionality (standard library 5 | # included) delivered in individual subpackages. The solution is to install 6 | # ruby + standard library via the 'ruby-full' package. 7 | RUN adduser -h /home/mdl -s /sbin/nologin -D -g mdl mdl && \ 8 | apk add --update --no-cache ruby-full && \ 9 | gem install mdl --no-document && \ 10 | mkdir /data 11 | WORKDIR /data 12 | USER mdl:mdl 13 | ENTRYPOINT ["mdl"] 14 | CMD ["--help"] 15 | -------------------------------------------------------------------------------- /tools/docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker container for markdownlint 2 | 3 | ## Using the docker image 4 | 5 | To check a single file: 6 | 7 | ```shell 8 | docker run --rm -v ${PWD}:/data markdownlint/markdownlint myfile.md 9 | ``` 10 | 11 | Or, to check all files in a directory: 12 | 13 | ```shell 14 | docker run --rm -v ${PWD}:/data markdownlint/markdownlint . 15 | ``` 16 | 17 | ## Building from a docker file 18 | 19 | The following will tag and upload a new release. Replace X.Y.Z as appropriate. 20 | 21 | ```shell 22 | podman build -t markdownlint/markdownlint:latest \ 23 | -t markdownlint/markdownlint:X.Y.Z . 24 | podman push markdownlint/markdownlint:latest 25 | podman push markdownlint/markdownling:X.Y.Z 26 | ``` 27 | -------------------------------------------------------------------------------- /test/rule_tests/fenced_code_blocks_in_lists.md: -------------------------------------------------------------------------------- 1 | # test doc 2 | 3 | this is some text 4 | 5 | * This is a list item 6 | 7 | ```fenced 8 | this is a code block within the list item. 9 | ``` 10 | 11 | with more text here 12 | 13 | * and another list item here 14 | 15 | And another paragraph. 16 | 17 | But this code block {MD046} 18 | 19 | is *NOT* in a list and should error. 20 | 21 | And in addition to that... 22 | 23 | ```text 24 | This code block is both indented 25 | and fenced and should *also* error. 26 | ``` 27 | 28 | And finally: 29 | 30 | ```text 31 | This is a code block 32 | 33 | And this is a code block in a code block and should *not* error 34 | 35 | More stuff here 36 | ``` 37 | 38 | all 39 | 40 | {MD046:23} 41 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Markdownlint 2 | 3 | We aim to make sure this project has longevity, and to that end we want to make 4 | adding new maintainers a simple process. 5 | 6 | If you would like to be a maintainer, you need to demonstrate some ongoing 7 | contributions - either reviews of PRs or PR contributions. They need not be large. 8 | Then send a PR to add yourself to this list. The existing maintainers will 9 | have a discussion, and assuming there are no objections, you'll be added. 10 | 11 | ## Current Maintainers 12 | 13 | * [Phil Dibowitz](https://github.com/jaymzh) 14 | * [Naomi Reeves](https://github.com/NaomiReeves) 15 | * [Bryan Wann](https://github.com/bwann) 16 | 17 | ## Past Maintainers 18 | 19 | * [psyomn](https://github.com/psyomn) 20 | * [Mark Harrison](https://github.com/mivok) 21 | -------------------------------------------------------------------------------- /lib/mdl/kramdown_parser.rb: -------------------------------------------------------------------------------- 1 | # Modified version of the kramdown parser to add in features/changes 2 | # appropriate for markdownlint, but which don't make sense to try to put 3 | # upstream. 4 | require 'kramdown/parser/gfm' 5 | 6 | module Kramdown 7 | module Parser 8 | # modified parser class - see comment above 9 | class MarkdownLint < Kramdown::Parser::Kramdown 10 | def initialize(source, options) 11 | super 12 | i = @block_parsers.index(:codeblock_fenced) 13 | @block_parsers.delete(:codeblock_fenced) 14 | @block_parsers.insert(i, :codeblock_fenced_gfm) 15 | end 16 | 17 | # Regular kramdown parser, but with GFM style fenced code blocks 18 | FENCED_CODEBLOCK_MATCH = Kramdown::Parser::GFM::FENCED_CODEBLOCK_MATCH 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /tools/test_location.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Script for identifying when markdownlint is receiving an incorrect line 3 | # number for an element. It checks all headers, then grabs the lines 4 | # associated with the headers according to markdown and compares the content, 5 | # printing out any that don't match. 6 | require 'mdl/doc' 7 | require 'pry' 8 | 9 | text = File.read(ARGV[0]) 10 | unless ARGV[1].nil? 11 | # If we provide a second argument, then start the document from line N of 12 | # the original file. 13 | text = text.split("\n")[ARGV[1].to_i - 1..].join("\n") 14 | end 15 | doc = MarkdownLint::Doc.new(text) 16 | headers = doc.find_type(:header) 17 | bad_headers = headers.select do |e| 18 | doc.element_line(e).nil? || !doc.element_line(e).include?(e[:raw_text]) 19 | end 20 | pp bad_headers 21 | -------------------------------------------------------------------------------- /test/rule_tests/code_block_dollar.md: -------------------------------------------------------------------------------- 1 | The following code block shouldn't have $ before the commands: 2 | 3 | ```bash 4 | $ ls 5 | $ less foo 6 | 7 | $ cat bar 8 | ``` 9 | 10 | However the following code block shows output, and $ can be used to 11 | distinguish between command and output: 12 | 13 | ```bash 14 | $ ls 15 | foo bar 16 | $ less foo 17 | Hello world 18 | 19 | $ cat bar 20 | baz 21 | ``` 22 | 23 | The following code block uses variable names, and likewise shouldn't fire: 24 | 25 | ```bash 26 | $foo = 'bar'; 27 | $baz = 'qux'; 28 | ``` 29 | 30 | The following code block doesn't have any dollar signs, and shouldn't fire: 31 | 32 | ```bash 33 | ls foo 34 | cat bar 35 | ``` 36 | 37 | The following (fenced) code block doesn't have any content at all, and 38 | shouldn't fire: 39 | 40 | ```bash 41 | ``` 42 | 43 | {MD014:3} 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: If something isn't working as expected 🤔. 4 | labels: "bug" 5 | --- 6 | ## Description 7 | 8 | 9 | ### Environment 10 | 11 | **MDL Version** 12 | 13 | 14 | ### Expected Behavior 15 | 16 | 17 | ### Actual Behavior 18 | 19 | 20 | ## Replication Case 21 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Enhancement Request 3 | about: I have a suggestion (and may want to implement it 🙂)! 4 | labels: "enhancement" 5 | --- 6 | 7 | ## Describe the Enhancement: 8 | 9 | 10 | ### Impacted Rules: 11 | 12 | 13 | ## Describe the Need: 14 | 15 | 16 | ## Current Alternative 17 | 18 | 19 | ## Can We Help You Implement This?: 20 | 21 | -------------------------------------------------------------------------------- /test/rule_tests/fenced_code_with_nesting.md: -------------------------------------------------------------------------------- 1 | # header 2 | 3 | text 4 | ```fence {MD031} 5 | code 6 | ``` {MD031} 7 | text 8 | ~~~fence {MD031} 9 | code 10 | ~~~ {MD031} 11 | text 12 | ```fence {MD031} 13 | ~~~fence 14 | code 15 | ~~~ 16 | ``` {MD031} 17 | text 18 | ~~~fence {MD031} 19 | ```fence 20 | code 21 | ``` 22 | ~~~ {MD031} 23 | text 24 | ```fence {MD031} 25 | 26 | ~~~fence 27 | code 28 | ~~~ 29 | 30 | ``` {MD031} 31 | text 32 | ~~~fence {MD031} 33 | 34 | ```fence 35 | code 36 | ``` 37 | 38 | ~~~ {MD031} 39 | text 40 | ```fence {MD031} 41 | code 42 | ~~~ 43 | ``` {MD031} 44 | text 45 | ~~~fence {MD031} 46 | code 47 | ``` 48 | ~~~ {MD031} 49 | text 50 | ````fence {MD031} 51 | ```fence 52 | code 53 | ``` 54 | ```` {MD031} 55 | text 56 | ~~~~fence {MD031} 57 | ~~~fence 58 | code 59 | ~~~ 60 | ~~~~ {MD031} 61 | text 62 | ````fence {MD031} 63 | ```fence 64 | code 65 | ``` 66 | ````` {MD031} 67 | text 68 | ~~~~fence {MD031} 69 | ~~~fence 70 | code 71 | ~~~ 72 | ~~~~~ {MD031} 73 | text 74 | -------------------------------------------------------------------------------- /test/rule_tests/spaces_inside_emphasis_markers.md: -------------------------------------------------------------------------------- 1 | Line with *Normal emphasis* 2 | 3 | Line with **Normal strong** 4 | 5 | Line with _Normal emphasis_ 6 | 7 | Line with __Normal strong__ 8 | 9 | Broken * emphasis * with spaces in {MD037} 10 | 11 | Broken ** strong ** with spaces in {MD037} 12 | 13 | Broken _ emphasis _ with spaces in {MD037} 14 | 15 | Broken __ strong __ with spaces in {MD037} 16 | 17 | Mixed *ok emphasis* and * broken emphasis * {MD037} 18 | 19 | Mixed **ok strong** and ** broken strong ** {MD037} 20 | 21 | Mixed _ok emphasis_ and _ broken emphasis _ {MD037} 22 | 23 | Mixed __ok strong__ and __ broken strong __ {MD037} 24 | 25 | Mixed *ok emphasis* **ok strong** * broken emphasis * {MD037} 26 | 27 | Multiple * broken emphasis * _ broken emphasis _ {MD037} 28 | 29 | One-sided *broken emphasis * {MD037} 30 | 31 | One-sided * broken emphasis* {MD037} 32 | 33 | Don't _flag on _words with underscores before them. 34 | 35 | The same goes for words* with asterisks* after them. 36 | -------------------------------------------------------------------------------- /test/rule_tests/lists_without_blank_lines.md: -------------------------------------------------------------------------------- 1 | * list (on first line) 2 | 3 | text 4 | 5 | * list 6 | 7 | text 8 | * list {MD032} 9 | text 10 | + list {MD032} 11 | text 12 | - list {MD032} 13 | text 14 | 1. list {MD032} 15 | text 16 | 17 | * list 18 | * list {MD032} 19 | text 20 | 21 | text 22 | 10. list {MD032} 23 | 20. list 24 | 25 | text 26 | 27 | * list 28 | * list 29 | * list 30 | 31 | text 32 | 33 | * list 34 | with hanging indent 35 | * list 36 | with hanging indent 37 | * list 38 | with hanging indent 39 | 40 | Note: list without hanging indent violates MD032 41 | 42 | * list 43 | 44 | item with blank lines 45 | 46 | * list 47 | 48 | item with blank lines 49 | 50 | text 51 | 52 | ```js 53 | /* 54 | * code block 55 | * not a list 56 | */ 57 | ``` 58 | 59 | text 60 | 61 | * list {MD032} 62 | ``` {MD031} 63 | code 64 | ``` 65 | 66 | text 67 | 68 | ``` 69 | code 70 | ``` {MD031} 71 | * list {MD032} 72 | 73 | text 74 | 75 | * list (on last line without newline){MD047} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | push: 4 | branches: main 5 | pull_request: 6 | branches: main 7 | jobs: 8 | markdown: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@main 13 | - name: Lint Markdown 14 | uses: actionshub/markdownlint@main 15 | with: 16 | filesToIgnoreRegex: '((test|example|bin)\/.*|docs\/RULES.md)' 17 | ruby: 18 | name: Ruby ${{ matrix.ruby }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: ['2.7', '3.0', '3.1', '3.2'] 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v2 27 | - name: Setup Ruby 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | - name: Install dependencies 32 | run: bundle install 33 | - name: Run rubocop 34 | run: bundle exec rubocop 35 | - name: Run tests 36 | run: bundle exec rake 37 | -------------------------------------------------------------------------------- /test/fixtures/docs_ruleset_2.rb: -------------------------------------------------------------------------------- 1 | require 'digest/md5' 2 | 3 | docs do |id, description| 4 | "https://example.com/#{id}##{Digest::MD5.hexdigest(description)}" 5 | end 6 | 7 | rule 'MY004', 'Documents must start with C' do 8 | tags :opinionated 9 | check do |doc| 10 | [1] if doc.lines[0] != 'C' 11 | end 12 | end 13 | 14 | rule 'MY005', 'Documents must start with D' do 15 | tags :opinionated 16 | docs 'https://example.com/override-docs' 17 | check do |doc| 18 | [1] if doc.lines[0] != 'D' 19 | end 20 | end 21 | 22 | rule 'MY007', 'Documents must start with F' do 23 | tags :opinionated 24 | 25 | docs do |id, description| 26 | hash = description.downcase.gsub(/[^a-z]+/, '-') 27 | "https://example.com/dynamic-override/#{id}##{hash}" 28 | end 29 | 30 | check do |doc| 31 | [1] if doc.lines[0] != 'F' 32 | end 33 | end 34 | 35 | docs 'https://example.com/later-declaration' 36 | 37 | rule 'MY006', 'Documents must start with E' do 38 | tags :opinionated 39 | check do |doc| 40 | [1] if doc.lines[0] != 'E' 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Mark Harrison 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/RULE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💪 Rule Request 3 | about: I have a suggestion for a new rule for markdownlint (and may want to implement it 🙌)! 4 | labels: "new rule" 5 | --- 6 | 7 | ## New Rule Checklist 8 | 9 | Before suggesting a rule for inclusion please make sure your suggestion meets these criteria for rule built into markdownlint: 10 | - [ ] allows a user to lint for a specific syntax divergence across the multiple flavors and styles of markdown 11 | - [ ] does not dictate any one specific style, but enables an end user to enable, disable, or configure the specific style of enforcement desired 12 | 13 | ## Describe The Rule: 14 | 15 | 16 | ## Why Should This Be Included In Markdownlint?: 17 | 18 | 19 | ## Can We Help You Implement This?: 20 | 21 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | NewCops: enable 4 | Exclude: 5 | - 'omnibus/bin/*' 6 | - 'vendor/**/*' 7 | 8 | Layout/LineLength: 9 | Max: 80 10 | 11 | Metrics: 12 | Enabled: false 13 | 14 | Style/FrozenStringLiteralComment: 15 | EnforcedStyle: never 16 | 17 | Style/LineEndConcatenation: 18 | Enabled: false 19 | 20 | Style/TrailingCommaInHashLiteral: 21 | EnforcedStyleForMultiline: comma 22 | 23 | Style/HashSyntax: 24 | EnforcedStyle: hash_rockets 25 | 26 | Style/PercentLiteralDelimiters: 27 | PreferredDelimiters: 28 | default: '{}' 29 | '%i': '{}' 30 | '%I': '{}' 31 | '%w': '{}' 32 | '%W': '{}' 33 | '%r': '{}' 34 | 35 | Style/TrailingCommaInArguments: 36 | EnforcedStyleForMultiline: comma 37 | 38 | Style/OptionalBooleanParameter: 39 | Enabled: false 40 | 41 | # once we have a customizable line-end concat, 42 | # then this will be be useful 43 | Style/StringConcatenation: 44 | Enabled: false 45 | 46 | Style/Lambda: 47 | EnforcedStyle: lambda 48 | 49 | # we do this a lot. it's very rubyish 50 | Style/MultilineBlockChain: 51 | Enabled: false 52 | 53 | Style/NumericPredicate: 54 | Enabled: false 55 | -------------------------------------------------------------------------------- /test/rule_tests/spaces_after_list_marker.md: -------------------------------------------------------------------------------- 1 | Normal list 2 | 3 | * Foo 4 | * Bar 5 | * Baz 6 | 7 | List with incorrect spacing 8 | 9 | * Foo {MD030} 10 | * Bar {MD030} 11 | * Baz {MD030} 12 | 13 | List with children: 14 | 15 | * Foo {MD030} 16 | * Bar {MD030} 17 | * Baz 18 | 19 | List with children and correct spacing: 20 | 21 | * Foo 22 | * Bar 23 | * Baz (This sublist has no children) 24 | 25 | List with Multiple paragraphs and correct spacing 26 | 27 | * Foo 28 | 29 | Here is the second paragraph 30 | 31 | * All items in the list need the same indent 32 | 33 | List with multiple paragraphs and incorrect spacing 34 | 35 | * Foo {MD030} 36 | 37 | Here is the second paragraph 38 | 39 | * Bar {MD030} 40 | 41 | List with code blocks: 42 | 43 | * Foo 44 | 45 | Here is some code 46 | 47 | * Bar 48 | 49 | Ordered lists: 50 | 51 | 1. Foo 52 | 1. Bar 53 | 1. Baz 54 | 55 | And with incorrect spacing: 56 | 57 | 1. Foo {MD030} 58 | 1. Bar {MD030} 59 | 1. Baz {MD030} 60 | 61 | Ordered lists with children: 62 | 63 | 1. Foo {MD030} 64 | * Hi 65 | 1. Bar {MD030} 66 | 1. Baz {MD030} 67 | 68 | Ordered lists with children (correct spacing), and with something other than 69 | the first item determining that the entire list has children: 70 | 71 | 1. Foo 72 | 1. Bar 73 | * Hi 74 | 1. Baz 75 | -------------------------------------------------------------------------------- /test/fixtures/output/sarif/with_matches.sarif: -------------------------------------------------------------------------------- 1 | {"$schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json","version":"2.1.0","runs":[{"tool":{"driver":{"name":"Markdown lint","version":"<%= MarkdownLint::VERSION %>","informationUri":"https://github.com/markdownlint/markdownlint","rules":[{"id":"MD002","name":"FirstHeaderH1","defaultConfiguration":{"level":"note"},"properties":{"description":"First header should be a top level header","tags":["headers"],"queryURI":"https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header"},"shortDescription":{"text":"First header should be a top level header"},"fullDescription":{"text":"First header should be a top level header"},"helpUri":"https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header","help":{"text":"More info: https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header","markdown":"[More info](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header)"}}]}},"results":[{"ruleId":"MD002","ruleIndex":0,"message":{"text":"MD002 - First header should be a top level header"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"(stdin)","uriBaseId":"%SRCROOT%"},"region":{"startLine":1}}}]}]}]} 2 | -------------------------------------------------------------------------------- /mdl.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'mdl/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'mdl' 7 | spec.version = MarkdownLint::VERSION 8 | spec.authors = ['Mark Harrison'] 9 | spec.email = ['mark@mivok.net'] 10 | spec.summary = 'Markdown lint tool' 11 | spec.description = 'Style checker/lint tool for markdown files' 12 | spec.homepage = 'https://github.com/markdownlint/markdownlint' 13 | spec.license = 'MIT' 14 | spec.metadata['rubygems_mfa_required'] = 'true' 15 | 16 | spec.files = %w{LICENSE.txt Gemfile} + Dir.glob('*.gemspec') + 17 | Dir.glob('lib/**/*') 18 | spec.bindir = 'bin' 19 | spec.executables = %w{mdl} 20 | spec.require_paths = ['lib'] 21 | 22 | spec.required_ruby_version = '>= 2.7' 23 | 24 | spec.add_dependency 'kramdown', '~> 2.3' 25 | spec.add_dependency 'kramdown-parser-gfm', '~> 1.1' 26 | spec.add_dependency 'mixlib-cli', '~> 2.1', '>= 2.1.1' 27 | spec.add_dependency 'mixlib-config', '>= 2.2.1', '< 4' 28 | spec.add_dependency 'mixlib-shellout' 29 | 30 | spec.add_development_dependency 'bundler', '>= 1.12', '< 3' 31 | spec.add_development_dependency 'minitest', '~> 5.9' 32 | spec.add_development_dependency 'pry', '~> 0.10' 33 | spec.add_development_dependency 'rake', '>= 11.2', '< 14' 34 | spec.add_development_dependency 'rubocop', '~> 1.28.1' 35 | end 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DESIGN_PROPOSAL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Proposal 3 | about: I have a significant change I would like to propose and discuss before starting 4 | labels: "design proposal" 5 | --- 6 | 7 | ### When a Change Needs a Design Proposal 8 | 9 | A design proposal should be opened any time a change meets one of the following qualifications: 10 | 11 | - Significantly changes the user experience of a project in a way that impacts users. 12 | - Significantly changes the underlying architecture of the project in a way that impacts other developers. 13 | - Changes the development or testing process of the project such as a change of CI systems or test frameworks. 14 | 15 | ### Why We Use This Process 16 | 17 | - Allows all interested parties (including any community member) to discuss large impact changes to a project. 18 | - Serves as a durable paper trail for discussions regarding project architecture. 19 | - Forces design discussions to occur before PRs are created. 20 | - Reduces PR refactoring and rejected PRs. 21 | 22 | --- 23 | 24 | 25 | 26 | ## Motivation 27 | 28 | 33 | 34 | ## Specification 35 | 36 | 37 | 38 | ## Downstream Impact 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Related Issues 5 | 6 | 7 | 8 | ## Types of changes 9 | 10 | - [ ] Bug fix (non-breaking change which fixes an issue) 11 | - [ ] New feature (non-breaking change which adds functionality) 12 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 13 | - [ ] Documentation (non-breaking change that does not add functionality but updates documentation) 14 | - [ ] Chore (non-breaking change that does not add functionality or fix an issue) 15 | 16 | ## Checklist: 17 | 18 | 19 | - [ ] I have read the [**CONTRIBUTING**](https://github.com/markdownlint/markdownlint/blob/main/CONTRIBUTING.md) document. 20 | - [ ] Wrote [good commit messages](https://chris.beams.io/posts/git-commit/) 21 | - [ ] Feature branch is up-to-date with `main`, if not - rebase it 22 | - [ ] Added tests for all new/changed functionality, including tests for positive and negative scenarios 23 | - [ ] The PR relates to *only* one subject with a clear title and description in grammatically correct, complete sentences 24 | -------------------------------------------------------------------------------- /docs/creating_styles.md: -------------------------------------------------------------------------------- 1 | # Creating styles 2 | 3 | A 'style' in markdownlint is simply a ruby file specifying the list of enabled 4 | and disabled rules, as well as specifying parameters for any rules that need 5 | parameters different than the defaults. 6 | 7 | The various options you can use in a style file are: 8 | 9 | * `all` - include all rules 10 | * `rule` - include a specific rule. 11 | 12 | ```ruby 13 | rule 'MD001' 14 | ``` 15 | 16 | * `exclude_rule` - exclude a previously included rule. Used if you want to 17 | include all except for a few rules. 18 | 19 | ```ruby 20 | exclude_rule 'MD000' 21 | ``` 22 | 23 | * `tag` - include all rules that are tagged with a specific value 24 | 25 | ```ruby 26 | tag :whitespace 27 | ``` 28 | 29 | * `exclude_tag` - exclude all rules tagged with the specified tag 30 | 31 | ```ruby 32 | exclude_tag :line_length 33 | ``` 34 | 35 | Note that tags are specified as symbols, and rule names as strings, just as in 36 | the rule definitions themselves. 37 | 38 | The last matching option wins, so you should always put `all` at the top of the 39 | file (if you want to include all rules), then tags (and tag excludes), then 40 | specific rules. In other words, go from least to most specific. 41 | 42 | ## Parameters 43 | 44 | If you specify any parameters after a rule ID, then those values will be used 45 | for the rules instead of the default. You only need to specify parameters for 46 | any values you wish to override. For example, the default values for the 47 | parameters in MD030 (spaces after list markers) are all 1. If you still want 48 | the spaces after the list markers to be 1 in some cases, then you can exclude 49 | those parameters: 50 | 51 | ```ruby 52 | rule 'MD030', :ol_multi => 2, :ul_multi => 3 53 | ``` 54 | 55 | Even if a rule is included already by a tag specification (or `all`), it is not 56 | a problem to add a specific `rule` entry in order to set custom parameters, and 57 | is in fact necessary to do so. 58 | -------------------------------------------------------------------------------- /test/test_rules.rb: -------------------------------------------------------------------------------- 1 | require_relative 'setup_tests' 2 | 3 | class TestRules < Minitest::Test 4 | def get_expected_errors(lines) 5 | # Looks for lines tagged with {MD123} to signify that a rule is expected to 6 | # fire for this line. It also looks for lines tagged with {MD123:1} to 7 | # signify that a rule is expected to fire on another line (the line number 8 | # after the colon). 9 | expected_errors = {} 10 | re = /\{(MD\d+)(?::(\d+))?\}/ 11 | lines.each_with_index do |line, num| 12 | m = re.match(line) 13 | while m 14 | expected_errors[m[1]] ||= [] 15 | expected_line = if m[2] 16 | m[2].to_i 17 | else 18 | num + 1 # 1 indexed lines 19 | end 20 | expected_errors[m[1]] << expected_line 21 | m = re.match(line, m.end(0)) 22 | end 23 | end 24 | expected_errors 25 | end 26 | 27 | def do_lint(filename) 28 | # Check for a test_case_style.rb style file for individual tests 29 | style_file = filename.sub(/.md$/, '_style.rb') 30 | unless File.exist?(style_file) 31 | style_file = "#{File.dirname(filename)}/default_test_style.rb" 32 | end 33 | 34 | ruleset = MarkdownLint::RuleSet.new 35 | ruleset.load_default 36 | rules = ruleset.rules 37 | style = MarkdownLint::Style.load(style_file, rules) 38 | rules.select! { |r| style.rules.include?(r) } 39 | 40 | doc = MarkdownLint::Doc.new(File.read(filename)) 41 | expected_errors = get_expected_errors(doc.lines) 42 | actual_errors = {} 43 | rules.sort.each do |id, rule| 44 | error_lines = rule.check.call(doc) 45 | actual_errors[id] = error_lines if error_lines && !error_lines.empty? 46 | end 47 | assert_equal expected_errors, actual_errors 48 | end 49 | 50 | Dir[File.expand_path('rule_tests/*.md', __dir__)].each do |filename| 51 | define_method("test_#{File.basename(filename, '.md')}") do 52 | do_lint(filename) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /docs/rolling_a_release.md: -------------------------------------------------------------------------------- 1 | # Rolling a new release 2 | 3 | Bump the version. Markdownlint uses semantic versioning. From 4 | : 5 | 6 | * Major version for backwards-incompatible changes 7 | * Minor version for functionality added in a backwards-compatible manner 8 | * Patch version for backwards-compatible bug fixes 9 | * Exception: Versions < 1.0 may introduce backwards-incompatible changes in a 10 | minor version. 11 | 12 | To bump the version, edit `lib/mdl/version.rb` and commit to the main branch. 13 | 14 | Update the changelog: 15 | 16 | * Add a new header and link for the new release, replacing any 'Unreleased' 17 | header. 18 | 19 | ```markdown 20 | ## [v0.2.0] (2015-04-13) 21 | 22 | This goes at the bottom: 23 | 24 | [v0.2.0]: https://github.com/markdownlint/markdownlint/tree/v0.2.0 25 | ``` 26 | 27 | * Changelog entries can and should be added in an 'Unreleased' section as 28 | commits are made. However, the following steps can be performed before each 29 | release to catch anything that was missed. 30 | * Add a 'Rules added' section, listing every new rule added for this version. 31 | * Use `git diff v0.1.0..v0.2.0 docs/RULES.md | grep '## MD'` to discover 32 | what these are. 33 | * Search for closed issues: 34 | * Go to 35 | * Search for `closed:>1900-01-01`, changing the date to the date 36 | of the last release. 37 | * From this list of issues, make sections for: 38 | * Added - for new features 39 | * Changed - for changes in existing functionality 40 | * Deprecated - for once-stable features removed in upcoming releases 41 | * Removed - for deprecated features removed in this release 42 | * Fixed - for any bug fixes 43 | * Security - for any security issues 44 | 45 | Next, run `rake release`. This will: 46 | 47 | * Tag vX.Y.Z in git 48 | * Upload the new gem to rubygems.org 49 | 50 | Then `git push --tags upstream` to push the tag. 51 | 52 | Build and push a docker image per the docs in tools/docker/README.md 53 | 54 | Finally, add a new 'Unreleased' section to the changelog for the next release. 55 | -------------------------------------------------------------------------------- /lib/mdl/ruleset.rb: -------------------------------------------------------------------------------- 1 | module MarkdownLint 2 | # defines a single rule 3 | class Rule 4 | attr_accessor :id, :description 5 | 6 | def initialize(id, description, fallback_docs: nil, &block) 7 | @id = id 8 | @description = description 9 | @generate_docs = fallback_docs 10 | @docs_overridden = false 11 | @aliases = [] 12 | @tags = [] 13 | @params = {} 14 | instance_eval(&block) 15 | end 16 | 17 | def check(&block) 18 | @check = block unless block.nil? 19 | @check 20 | end 21 | 22 | def tags(*tags) 23 | @tags = tags.flatten.map(&:to_sym) unless tags.empty? 24 | @tags 25 | end 26 | 27 | def aliases(*aliases) 28 | @aliases.concat(aliases) 29 | @aliases 30 | end 31 | 32 | def params(params = nil) 33 | @params.update(params) unless params.nil? 34 | @params 35 | end 36 | 37 | def docs(url = nil, &block) 38 | if block_given? != url.nil? 39 | raise ArgumentError, 'Give either a URL or a block, not both' 40 | end 41 | 42 | raise 'A docs url is already set within this rule' if @docs_overridden 43 | 44 | @generate_docs = block_given? ? block : lambda { |_, _| url } 45 | @docs_overridden = true 46 | end 47 | 48 | def docs_url 49 | @generate_docs&.call(id, description) 50 | end 51 | end 52 | 53 | # defines a ruleset 54 | class RuleSet 55 | attr_reader :rules 56 | 57 | def initialize 58 | @rules = {} 59 | end 60 | 61 | def rule(id, description, &block) 62 | @rules[id] = 63 | Rule.new(id, description, :fallback_docs => @fallback_docs, &block) 64 | end 65 | 66 | def load(rules_file) 67 | instance_eval(File.read(rules_file), rules_file) 68 | @rules 69 | end 70 | 71 | def docs(url = nil, &block) 72 | if block_given? != url.nil? 73 | raise ArgumentError, 'Give either a URL or a block, not both' 74 | end 75 | 76 | @fallback_docs = block_given? ? block : lambda { |_, _| url } 77 | end 78 | 79 | def load_default 80 | load(File.expand_path('rules.rb', __dir__)) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/rule_tests/emphasis_instead_of_headers.md: -------------------------------------------------------------------------------- 1 | **Section 1: the first section {MD036}** 2 | 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 4 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis 5 | nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 6 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore 7 | eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt 8 | in culpa qui officia deserunt mollit anim id est laborum. 9 | 10 | __Section 1.1: another section {MD036}__ 11 | 12 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 13 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis 14 | nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore 16 | eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt 17 | in culpa qui officia deserunt mollit anim id est laborum. 18 | 19 | *Section 2: yet more sections {MD036}* 20 | 21 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor 22 | incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis 23 | nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 24 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore 25 | eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt 26 | in culpa qui officia deserunt mollit anim id est laborum. 27 | 28 | _Section 3: oh no more sections {MD036}_ 29 | 30 | This is a normal paragraph 31 | **that just happens to have emphasized text in** 32 | even though the emphasized text is on its own line. 33 | 34 | This is another **normal** paragraph with some text in it. This also should 35 | not trigger the rule. 36 | 37 | **This is an entire paragraph that has been emphasized, and shouldn't be 38 | detected as a header because it's on multiple lines** 39 | 40 | **This also shouldn't be detected as a header as it ends in punctuation.** 41 | 42 | **[This as well since it is a link](https://example.com)** 43 | -------------------------------------------------------------------------------- /test/test_ruledocs.rb: -------------------------------------------------------------------------------- 1 | require_relative 'setup_tests' 2 | 3 | # Ensures there is documentation for every rule, and that the 4 | # descriptions/tags/etc in the rule match those in the documentation 5 | # rubocop:disable Style/ClassVars 6 | class TestRuledocs < Minitest::Test 7 | @@ruleset = MarkdownLint::RuleSet.new 8 | @@ruleset.load_default 9 | @@rules = @@ruleset.rules 10 | 11 | def setup 12 | @ruledocs = load_ruledocs 13 | end 14 | 15 | def load_ruledocs 16 | rules = Hash.new({}) # Default to {} if no docs for the rule 17 | curr_rule = nil 18 | rules_file = File.expand_path('../docs/RULES.md', __dir__) 19 | File.read(rules_file).split("\n").each do |l| 20 | case l 21 | when /^## (MD\d+) - (.*)$/ 22 | rules[Regexp.last_match(1)] = { 23 | :description => Regexp.last_match(2), :params => {} 24 | } 25 | curr_rule = Regexp.last_match(1) 26 | when /^Tags: (.*)$/ 27 | rules[curr_rule][:tags] = Regexp.last_match(1).split(',').map do |i| 28 | i.strip.to_sym 29 | end 30 | when /^Aliases: (.*)$/ 31 | rules[curr_rule][:aliases] = Regexp.last_match(1).split(',') 32 | .map(&:strip) 33 | when /^Parameters: (.*)(\(.*\)?)$/ 34 | rules[curr_rule][:params] = Regexp.last_match(1).split(',').map do |i| 35 | i.strip.to_sym 36 | end 37 | end 38 | end 39 | rules 40 | end 41 | 42 | @@rules.each do |id, r| 43 | define_method("test_ruledoc_description_#{id}") do 44 | assert_equal r.description, @ruledocs[id][:description] 45 | end 46 | define_method("test_ruledoc_tags_#{id}") do 47 | assert_equal r.tags, @ruledocs[id][:tags] 48 | end 49 | define_method("test_ruledoc_aliases_#{id}") do 50 | assert_equal r.aliases, @ruledocs[id][:aliases] 51 | end 52 | define_method("test_ruledoc_params_#{id}") do 53 | assert_equal r.params.keys.sort, (@ruledocs[id][:params] || []).sort 54 | end 55 | end 56 | 57 | def test_ruledoc_for_every_rule 58 | # (and vice versa) 59 | assert_equal @@rules.keys, @ruledocs.keys 60 | end 61 | end 62 | # rubocop:enable Style/ClassVars 63 | -------------------------------------------------------------------------------- /lib/mdl/style.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module MarkdownLint 4 | # defines a style 5 | class Style 6 | attr_reader :rules 7 | 8 | def initialize(all_rules) 9 | @tagged_rules = {} 10 | @aliases = {} 11 | all_rules.each do |id, r| 12 | r.tags.each do |t| 13 | @tagged_rules[t] ||= Set.new 14 | @tagged_rules[t] << id 15 | end 16 | r.aliases.each do |a| 17 | @aliases[a] = id 18 | end 19 | end 20 | @all_rules = all_rules 21 | @rules = Set.new 22 | end 23 | 24 | def all 25 | @rules.merge(@all_rules.keys) 26 | end 27 | 28 | def rule(id, params = {}) 29 | if block_given? 30 | raise '"rule" does not take a block. Should this definition go in a ' + 31 | 'ruleset instead?' 32 | end 33 | 34 | id = @aliases[id] if @aliases[id] 35 | raise "No such rule: #{id}" unless @all_rules[id] 36 | 37 | @rules << id 38 | @all_rules[id].params(params) 39 | end 40 | 41 | def exclude_rule(id) 42 | id = @aliases[id] if @aliases[id] 43 | @rules.delete(id) 44 | end 45 | 46 | def tag(tag) 47 | @rules.merge(@tagged_rules[tag]) 48 | end 49 | 50 | def exclude_tag(tag) 51 | @rules.subtract(@tagged_rules[tag]) 52 | end 53 | 54 | def self.load(style_file, rules) 55 | unless style_file.include?('/') || style_file.end_with?('.rb') 56 | tmp = File.expand_path("../styles/#{style_file}.rb", __FILE__) 57 | unless File.exist?(tmp) 58 | warn "#{style_file} does not appear to be a built-in style." + 59 | ' If you meant to pass in your own style file, it must contain' + 60 | " a '/' or end in '.rb'. See https://github.com/markdownlint/" + 61 | 'markdownlint/blob/main/docs/configuration.md' 62 | exit(1) 63 | end 64 | style_file = tmp 65 | end 66 | 67 | unless File.exist?(style_file) 68 | warn "Style '#{style_file}' does not exist." 69 | exit(1) 70 | end 71 | 72 | style = new(rules) 73 | style.instance_eval(File.read(style_file), style_file) 74 | rules.select! { |r| style.rules.include?(r) } 75 | style 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/rule_tests/long_lines_code.md: -------------------------------------------------------------------------------- 1 | This is a short line. 2 | 3 | This is a very very very very very very very very very very very very very very very very very very very very long line. {MD013} 4 | 5 | This is a short line. 6 | 7 | ```text 8 | Here is a short line in a code block. 9 | Here is a very very very very very very very very very very very very very very very very very very very long line in a code block. 10 | ``` 11 | 12 | ```text 13 | test 14 | test 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut 17 | ``` 18 | 19 | This is a short line. 20 | 21 | | First Header | Second Header | Third Header | Fourth Header | Fifth Header | Sixth Header | 22 | | ------------- | ------------- | ------------- | ------------- | ------------- | ------------- | 23 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 24 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 25 | | ------------- | ------------- | ------------- | ------------- | ------------- | ------------- | 26 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 27 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 28 | | ============= | ============= | ============= | ============= | ============= | ============= | 29 | | Footer Cell | Footer Cell | Footer Cell | Footer Cell | Footer Cell | Footer Cell | 30 | {: rules="groups"} 31 | 32 | This is a very very very very very very very very very very very very very very very very very very very very long line. {MD013} 33 | 34 | Another line. 35 | 36 | | First Header | Second Header | Third Header | Fourth Header | Fifth Header | Sixth Header | 37 | | ------------- | ------------- | ------------- | ------------- | ------------- | ------------- | 38 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 39 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 40 | | ------------- | ------------- | ------------- | ------------- | ------------- | ------------- | 41 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 42 | | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | Content Cell | 43 | | ============= | ============= | ============= | ============= | ============= | ============= | 44 | | Footer Cell | Footer Cell | Footer Cell | Footer Cell | Footer Cell | Footer Cell | 45 | {: rules="groups"} 46 | -------------------------------------------------------------------------------- /lib/mdl/formatters/sarif.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module MarkdownLint 4 | # SARIF formatter 5 | # 6 | # @see https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html 7 | class SarifFormatter 8 | class << self 9 | def generate(rules, results) 10 | matched_rules_id = results.map { |result| result['rule'] }.uniq 11 | matched_rules = rules.select { |id, _| matched_rules_id.include?(id) } 12 | JSON.generate(generate_sarif(matched_rules, results)) 13 | end 14 | 15 | def generate_sarif(rules, results) 16 | { 17 | :'$schema' => 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', 18 | :version => '2.1.0', 19 | :runs => [ 20 | { 21 | :tool => { 22 | :driver => { 23 | :name => 'Markdown lint', 24 | :version => MarkdownLint::VERSION, 25 | :informationUri => 'https://github.com/markdownlint/markdownlint', 26 | :rules => generate_sarif_rules(rules), 27 | }, 28 | }, 29 | :results => generate_sarif_results(rules, results), 30 | } 31 | ], 32 | } 33 | end 34 | 35 | def generate_sarif_rules(rules) 36 | rules.map do |id, rule| 37 | { 38 | :id => id, 39 | :name => rule.aliases.first.split('-').map(&:capitalize).join, 40 | :defaultConfiguration => { 41 | :level => 'note', 42 | }, 43 | :properties => { 44 | :description => rule.description, 45 | :tags => rule.tags, 46 | :queryURI => rule.docs_url, 47 | }, 48 | :shortDescription => { 49 | :text => rule.description, 50 | }, 51 | :fullDescription => { 52 | :text => rule.description, 53 | }, 54 | :helpUri => rule.docs_url, 55 | :help => { 56 | :text => "More info: #{rule.docs_url}", 57 | :markdown => "[More info](#{rule.docs_url})", 58 | }, 59 | } 60 | end 61 | end 62 | 63 | def generate_sarif_results(rules, results) 64 | results.map do |result| 65 | { 66 | :ruleId => result['rule'], 67 | :ruleIndex => rules.find_index { |id, _| id == result['rule'] }, 68 | :message => { 69 | :text => "#{result['rule']} - #{result['description']}", 70 | }, 71 | :locations => [ 72 | { 73 | :physicalLocation => { 74 | :artifactLocation => { 75 | :uri => result['filename'], 76 | :uriBaseId => '%SRCROOT%', 77 | }, 78 | :region => { 79 | :startLine => result['line'], 80 | }, 81 | }, 82 | } 83 | ], 84 | } 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown lint tool 2 | 3 | [![Continuous Integration](https://github.com/markdownlint/markdownlint/workflows/Continuous%20Integration/badge.svg)](https://github.com/markdownlint/markdownlint/actions?query=workflow%3A%22Continuous+Integration%22) 4 | [![Gem Version](https://badge.fury.io/rb/mdl.svg)](https://badge.fury.io/rb/mdl) 5 | 6 | A tool to check markdown files and flag style issues. 7 | 8 | ## Installation 9 | 10 | Markdownlint is packaged in some distributions as well as distributed via 11 | RubyGems. Check the list below to see if it's packaged for your distribution, 12 | and if so, feel free to use your distros package manager to install it. 13 | 14 | [![Packaging status](https://repology.org/badge/vertical-allrepos/mdl-markdownlint.svg?exclude_unsupported=1)](https://repology.org/project/mdl-markdownlint/versions) 15 | 16 | To install from rubygems, run: 17 | 18 | ```shell 19 | gem install mdl 20 | ``` 21 | 22 | Alternatively you can build it from source: 23 | 24 | ```shell 25 | git clone https://github.com/markdownlint/markdownlint 26 | cd markdownlint 27 | rake install 28 | ``` 29 | 30 | Note that you will need [rake](https://github.com/ruby/rake) 31 | (`gem install rake`) and [bundler](https://github.com/bundler/bundler) 32 | (`gem install bundler`) in order to build from source. 33 | 34 | ## Usage 35 | 36 | To have markdownlint check your markdown files, simply run `mdl` with the 37 | filenames as a parameter: 38 | 39 | ```shell 40 | mdl README.md 41 | ``` 42 | 43 | Markdownlint can also take a directory, and it will scan all markdown files 44 | within the directory (and nested directories): 45 | 46 | ```shell 47 | mdl docs/ 48 | ``` 49 | 50 | If you don't specify a filename, markdownlint will use stdin: 51 | 52 | ```shell 53 | cat foo.md | mdl 54 | ``` 55 | 56 | Markdownlint will output a list of issues it finds, and the line number where 57 | the issue is. See [RULES.md](docs/RULES.md) for information on each issue, as 58 | well as how to correct it: 59 | 60 | ```shell 61 | README.md:1: MD013 Line length 62 | README.md:70: MD029 Ordered list item prefix 63 | README.md:71: MD029 Ordered list item prefix 64 | README.md:72: MD029 Ordered list item prefix 65 | README.md:73: MD029 Ordered list item prefix 66 | ``` 67 | 68 | Markdownlint has many more options you can pass on the command line, run 69 | `mdl --help` to see what they are, or see the documentation on 70 | [configuring markdownlint](docs/configuration.md). 71 | 72 | ### Styles 73 | 74 | Not everyone writes markdown in the same way, and there are multiple flavors 75 | and styles, each of which are valid. While markdownlint's default settings 76 | will result in markdown files that reflect the author's preferred markdown 77 | authoring preferences, your project may have different guidelines. 78 | 79 | It's not markdownlint's intention to dictate any one specific style, and in 80 | order to support these differing styles and/or preferences, markdownlint 81 | supports what are called 'style files'. A style file is a file describing 82 | which rules markdownlint should enable, and also what settings to apply to 83 | individual rules. For example, rule [MD013](docs/RULES.md#md013---line-length) 84 | checks for long lines, and by default will report an issue for any line longer 85 | than 80 characters. If your project has a different maximum line length limit, 86 | or if you don't want to enforce a line limit at all, then this can be 87 | configured in a style file. 88 | 89 | For more information on creating style files, see the 90 | [creating styles](docs/creating_styles.md) document. 91 | 92 | ### Custom rules and rulesets 93 | 94 | It may be that the rules provided in this project don't cover your stylistic 95 | needs. To account for this, markdownlint supports the creation and use of custom 96 | rules. 97 | 98 | For more information, see the [creating rules](docs/creating_rules.md) document. 99 | 100 | ## Related projects 101 | 102 | - [markdownlint](https://github.com/DavidAnson/markdownlint) - A similar 103 | project, but limited in Node.js 104 | - [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli) - A CLI 105 | for the above Node.js project 106 | - [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) - An 107 | alternative CLI for the Node.js project 108 | 109 | ## Contributing 110 | 111 | See [CONTRIBUTING.md](CONTRIBUTING.md) for more information. 112 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Mdl configuration 2 | 3 | Markdownlint has several options you can configure both on the command line, or 4 | in markdownlint's configuration file: `.mdlrc`, first looked for in the working 5 | directory, then in your home directory. While markdownlint will work perfectly 6 | well out of the box, this page documents some of the options you can change to 7 | suit your needs. 8 | 9 | In general, anything you pass on the command line can also be put into 10 | `~/.mdlrc` with the same option. For example, if you pass `--style foo` on the 11 | command line, you can make this the default by putting `style "foo"` into your 12 | `~/.mdlrc` file. 13 | 14 | ## Configuration options 15 | 16 | ### General options 17 | 18 | Verbose - Print additional information about what markdownlint is doing. 19 | 20 | * Command line: `-v`, `--verbose` 21 | * Config file: `verbose true` 22 | * Default: false 23 | 24 | Show warnings - Kramdown will generate warnings of its own for some issues 25 | found with documents during parsing, and markdownlint can print these out in 26 | addition to using the built in rules. This option enables/disables that 27 | behavior. 28 | 29 | * Command line: `-w`, `--warnings` 30 | * Config file: `show_kramdown_warnings true` 31 | * Default: true 32 | 33 | Recurse using files known to git - When mdl is given a directory name on the 34 | command line, it will recurse into that directory looking for markdown files to 35 | process. If this option is enabled, it will use git to look for files instead, 36 | and ignore any files git doesn't know about. 37 | 38 | * Command line: `-g`, `--git-recurse` 39 | * Config file: `git_recurse true` 40 | * Default: false 41 | 42 | Ignore YAML front matter - If this option is enabled markdownlint will ignore 43 | content within valid [YAML front 44 | matter](https://jekyllrb.com/docs/frontmatter/). Reported line numbers will 45 | still match the file contents but markdownlint will consider the line following 46 | front matter to be the first line. 47 | 48 | * Command line: `-i`, `--ignore-front-matter` 49 | * Config file: `ignore_front_matter true` 50 | * Default: false 51 | 52 | ### Specifying which rules mdl processes 53 | 54 | Tags - Limit the rules mdl enables to those containing the provided tags. 55 | 56 | * Command line: `-t tag1,tag2`, `--tags tag1,tag2`, `-t ~tag1,~tag2` 57 | * Config file: `tags "tag1", "tag2"` 58 | * Default: process all rules (no tag limit) 59 | 60 | Rules - Limit the rules mdl enables to those provided in this option. 61 | 62 | * Command line: `-r MD001,MD002`, `--rules MD001,MD002`, `-r ~MD001,~MD002` 63 | * Config file: `rules "MD001", "MD002"` 64 | * Default: process all rules (no rule limit) 65 | 66 | If a rule or tag ID is preceded by a tilde (`~`), then it _disables_ the 67 | matching rules instead of enabling them, starting with all rules being enabled. 68 | 69 | Note: If both `--rules` and `--tags` are provided, then a given rule has to 70 | both be in the list of enabled rules, as well as be tagged with one of the tags 71 | provided with the `--tags` option. Use the `-l/--list-rules` option to test 72 | this behavior. 73 | 74 | Style - Select which style mdl uses. A 'style' is a file containing a list of 75 | enabled/disable rules, as well as options for some rules that take them. For 76 | example, one style might enforce a line length of 80 characters, while another 77 | might choose 72 characters, and another might have no line length limit at all 78 | (rule MD013). 79 | 80 | * Command line: `-s style_name`, `--style style_name` 81 | * Config file: `style "style_name"` 82 | * Default: Use the style called 'default' 83 | 84 | Note: The value for `style_name` must either end with `.rb` or have `/` in it 85 | in order to tell `mdl` to look for a custom style, and not a built-in style. 86 | 87 | Note: When setting `style` in `mdlrc`, it is highly recommended that either a 88 | fully-qualified path be used, or that the relative values be passed in a form 89 | like `File.join(File.dirname(__FILE__), '.mdl.style')` so that the value is 90 | relative to the `mdlrc` and not the path the user happens to be in. 91 | 92 | Rulesets - Load a custom ruleset file. This option allows you to load custom 93 | rules in addition to those included with markdownlint. 94 | 95 | * Command line: `-u ruleset.rb,ruleset2.rb`, `--rulesets ruleset.rb,ruleset2.rb` 96 | * Config file: `rulesets ['ruleset.rb', 'ruleset2.rb']` 97 | * Default: Don't load any additional rulesets 98 | 99 | No default ruleset - Skip loading the default ruleset file included with 100 | markdownlint. Use this option if you only want to load custom rulesets. 101 | 102 | * Command line: `-d`, `--skip-default-ruleset` 103 | * Config file: `skip_default_ruleset true` 104 | * Default: Load the default ruleset. 105 | 106 | ## Creating your own .mdlrc files 107 | 108 | You can configure `mdl` using your own `.mdlrc` file. You can specify and 109 | command-line option in this file. 110 | 111 | In particular you can specify a `style` file, where you have configured any 112 | rules. Here's a simple example that just points to a style file: 113 | 114 | ```ruby 115 | style "#{File.dirname(__FILE__)}/{your_markdown_rule_file_path}.rb" 116 | ``` 117 | 118 | As commented, this path is relative to `.mdlrc` file. You can find a basic 119 | example of `.mdlrc` file [here](../example/.mdlrc_example). 120 | 121 | Then you should create your new [rule](creating_rules.md) or 122 | [style](creating_styles.md). 123 | 124 | You can find a basic example of new style file 125 | [here](../example/new_style_example.rb). 126 | -------------------------------------------------------------------------------- /lib/mdl.rb: -------------------------------------------------------------------------------- 1 | require_relative 'mdl/formatters/sarif' 2 | require_relative 'mdl/cli' 3 | require_relative 'mdl/config' 4 | require_relative 'mdl/doc' 5 | require_relative 'mdl/kramdown_parser' 6 | require_relative 'mdl/ruleset' 7 | require_relative 'mdl/style' 8 | require_relative 'mdl/version' 9 | 10 | require 'kramdown' 11 | require 'mixlib/shellout' 12 | 13 | # Primary MDL container 14 | module MarkdownLint 15 | def self.run(argv = ARGV) 16 | cli = MarkdownLint::CLI.new 17 | cli.run(argv) 18 | ruleset = RuleSet.new 19 | ruleset.load_default unless Config[:skip_default_ruleset] 20 | Config[:rulesets]&.each do |r| 21 | ruleset.load(r) 22 | end 23 | rules = ruleset.rules 24 | Style.load(Config[:style], rules) 25 | # Rule option filter 26 | if Config[:rules] 27 | unless Config[:rules][:include].empty? 28 | rules.select! do |r, v| 29 | Config[:rules][:include].include?(r) or 30 | !(Config[:rules][:include] & v.aliases).empty? 31 | end 32 | end 33 | unless Config[:rules][:exclude].empty? 34 | rules.select! do |r, v| 35 | !Config[:rules][:exclude].include?(r) and 36 | (Config[:rules][:exclude] & v.aliases).empty? 37 | end 38 | end 39 | end 40 | # Tag option filter 41 | if Config[:tags] 42 | rules.reject! { |_r, v| (v.tags & Config[:tags][:include]).empty? } \ 43 | unless Config[:tags][:include].empty? 44 | rules.select! { |_r, v| (v.tags & Config[:tags][:exclude]).empty? } \ 45 | unless Config[:tags][:exclude].empty? 46 | end 47 | 48 | if Config[:list_rules] 49 | puts 'Enabled rules:' 50 | rules.each do |id, rule| 51 | if Config[:verbose] 52 | puts "#{id} (#{rule.aliases.join(', ')}) [#{rule.tags.join(', ')}] " + 53 | "- #{rule.description}" 54 | elsif Config[:show_aliases] 55 | puts "#{rule.aliases.first || id} - #{rule.description}" 56 | else 57 | puts "#{id} - #{rule.description}" 58 | end 59 | end 60 | exit 0 61 | end 62 | 63 | # Recurse into directories 64 | cli.cli_arguments.each_with_index do |filename, i| 65 | if Dir.exist?(filename) 66 | if Config[:git_recurse] 67 | Dir.chdir(filename) do 68 | cli.cli_arguments[i] = 69 | Mixlib::ShellOut.new("git ls-files '*.md' '*.markdown'") 70 | .run_command.stdout.lines 71 | .map { |m| File.join(filename, m.strip) } 72 | end 73 | else 74 | cli.cli_arguments[i] = Dir["#{filename}/**/*.{md,markdown}"] 75 | end 76 | end 77 | end 78 | cli.cli_arguments.flatten! 79 | 80 | status = 0 81 | results = [] 82 | docs_to_print = [] 83 | cli.cli_arguments.each do |filename| 84 | puts "Checking #{filename}..." if Config[:verbose] 85 | unless filename == '-' || File.exist?(filename) 86 | warn( 87 | "#{Errno::ENOENT}: No such file or directory - #{filename}", 88 | ) 89 | exit 3 90 | end 91 | doc = Doc.new_from_file(filename, Config[:ignore_front_matter]) 92 | filename = '(stdin)' if filename == '-' 93 | if Config[:show_kramdown_warnings] 94 | status = 2 unless doc.parsed.warnings.empty? 95 | doc.parsed.warnings.each do |w| 96 | puts "#{filename}: Kramdown Warning: #{w}" 97 | end 98 | end 99 | rules.sort.each do |id, rule| 100 | puts "Processing rule #{id}" if Config[:verbose] 101 | error_lines = rule.check.call(doc) 102 | next if error_lines.nil? || error_lines.empty? 103 | 104 | status = 1 105 | error_lines.each do |line| 106 | line += doc.offset # Correct line numbers for any yaml front matter 107 | if Config[:json] || Config[:sarif] 108 | results << { 109 | 'filename' => filename, 110 | 'line' => line, 111 | 'rule' => id, 112 | 'aliases' => rule.aliases, 113 | 'description' => rule.description, 114 | 'docs' => rule.docs_url, 115 | } 116 | else 117 | linked_id = linkify(printable_id(rule), rule.docs_url) 118 | puts "#{filename}:#{line}: #{linked_id} " + rule.description.to_s 119 | end 120 | end 121 | 122 | # If we're not in JSON or SARIF mode (URLs are in the object), and we 123 | # cannot make real links (checking if we have a TTY is an OK heuristic 124 | # for that) then, instead of making the output ugly with long URLs, we 125 | # print them at the end. And of course we only want to print each URL 126 | # once. 127 | if !Config[:json] && !Config[:sarif] && 128 | !$stdout.tty? && !docs_to_print.include?(rule) 129 | docs_to_print << rule 130 | end 131 | end 132 | end 133 | 134 | if Config[:json] 135 | require 'json' 136 | puts JSON.generate(results) 137 | elsif Config[:sarif] 138 | puts SarifFormatter.generate(rules, results) 139 | elsif docs_to_print.any? 140 | puts "\nFurther documentation is available for these failures:" 141 | docs_to_print.each do |rule| 142 | puts " - #{printable_id(rule)}: #{rule.docs_url}" 143 | end 144 | end 145 | exit status 146 | end 147 | 148 | def self.printable_id(rule) 149 | return rule.aliases.first if Config[:show_aliases] && rule.aliases.any? 150 | 151 | rule.id 152 | end 153 | 154 | # Creates hyperlinks in terminal emulators, if available: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda 155 | def self.linkify(text, url) 156 | return text unless $stdout.tty? && url 157 | 158 | "\e]8;;#{url}\e\\#{text}\e]8;;\e\\" 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /docs/creating_rules.md: -------------------------------------------------------------------------------- 1 | # Creating Rules and Rulesets 2 | 3 | If the rules provided in this project don't cover your stylistic needs then you 4 | can create a custom ruleset. A ruleset is a Ruby file that contains rules. Rules 5 | are small pieces of Ruby code; these are [described below](#rule-syntax). 6 | 7 | ## Using custom rules 8 | 9 | When a custom ruleset has been created, the ruleset can be loaded using the 10 | configuration options described in [the configuration 11 | document](./configuration.md) and referred to in the style document. 12 | 13 | As a minimal example: if `foo.rb` is a custom ruleset in the same directory as 14 | `.mdlrc`, and if `foo.rb` contains the custom rules `BAR001` and `BAR002`, then 15 | these can be used by adding the following lines to `.mdlrc` 16 | 17 | ```ruby 18 | rulesets ["#{File.dirname(__FILE__)}/foo.rb"] 19 | style "#{File.dirname(__FILE__)}/baz.rb" 20 | ``` 21 | 22 | where `baz.rb` is a style file that contains the contents 23 | 24 | ```ruby 25 | rule "BAR001" 26 | rule "BAR002" 27 | ``` 28 | 29 | ## Rule syntax 30 | 31 | Rules are written in ruby, using a rule DSL for defining rules. A rule looks 32 | like: 33 | 34 | ```ruby 35 | rule "MY000", "Rule description" do 36 | tags :foo, :bar 37 | docs 'https://docs.example.org/more/info#MY000' 38 | aliases 'rule-name' 39 | params :style => :foo 40 | check do |doc| 41 | # check code goes here 42 | # return a list of line numbers that break the rule, or an empty list 43 | # (or nil) if there are no problems. 44 | end 45 | end 46 | ``` 47 | 48 | The first line specifies the rule name and description. By convention, built in 49 | markdownlint rules use the prefix 'MD' followed by a number to identify rules. 50 | Any custom rules should use an alternate prefix to avoid conflicting with 51 | current or future rules. The description is simply a short description 52 | explaining what the rule is checking, which will be printed alongside the rule 53 | name when rules are triggered. 54 | 55 | Next, the rule's tags are specified. These are simply ruby symbols, and can be 56 | used by a user to limit which rules are checks. For example, if your rule 57 | checks whitespace usage in a document, you can add the `:whitespace` tag, and 58 | users who don't care about whitespace can exclude that tag on the command line 59 | or in style files. 60 | 61 | You can optionally provide a URL with more context on this rule and why it 62 | exists. `markdownlint` links to this URL when this rule fails, which can help 63 | the people receiving a failure understand how to fix it. Docs URLs can also be 64 | specified once for many rules in a ruleset, the built-in rules 65 | [use this feature]( 66 | https://github.com/markdownlint/markdownlint/blob/81e99c03f1f096aa200011ff7a1043a6f81167e7/lib/mdl/rules.rb#L1-L5 67 | ) to set a URL dynamically using the rule id and/or description. 68 | 69 | You can also specify aliases for the rule, which can be used to refer to the 70 | rule with a human-readable name rather than MD000. To do this, add then with 71 | the 'aliases' directive. Whenever you refer to a rule, such as for 72 | including/excluding in the configuration or in style files, you can use an 73 | alias for the rule instead of its ID. 74 | 75 | After that, any parameters the rule takes are specified. If your rule checks 76 | for a specific number of things, or if you can envision multiple variants of 77 | the same rule, then you should add parameters to allow your rule to be 78 | customized in a style file. Any parameters specified here are accessible inside 79 | the check itself using `params[:foo]`. 80 | 81 | Finally, the check itself is specified. This is simply a ruby block that should 82 | return a list of line numbers for any issues found. If no line numbers are 83 | found, you can either return an empty list, or nil, whichever is easiest for 84 | your check. 85 | 86 | ### Document objects 87 | 88 | The check takes a single parameter `doc`, which is an object containing a 89 | representation of the markdown document along with several helper functions 90 | used for making rules. The [doc.rb](../lib/mdl/doc.rb) file is documented using 91 | rdoc, and you will want to take a look there to see all the methods you can 92 | use, as well as look at some of the existing rules, but a quick summary is as 93 | follows: 94 | 95 | * `doc` - Object containing a representation of the markdown document 96 | * `doc.lines` - The raw markdown file as an array of lines 97 | * You can also look up a line given an element with 98 | `doc.element_line(element)` 99 | * `doc.parsed` - The kramdown internal representation of the doc. Most of the 100 | time you will want to interact with the parsed version of the document 101 | rather than looking at `doc.lines`. 102 | * `doc.find_type_elements` - A method to find all elements of a given type. 103 | You pass the type as a symbol, such as `:ul` or `:p`. Most element types 104 | match the name of the element in HTML output. This method returns a list of 105 | the matching elements. 106 | * `doc.find_type` - This is like `doc.find_type_elements`, but returns just 107 | the options hashes (see below) for each element. This is useful if you don't 108 | need all the element information, but you do need the line numbers. 109 | * `doc.element_line_number` - Pass in an element (or an options hash), and 110 | this will return the line number for the element. You need to return the 111 | line number in the list of errors. 112 | 113 | ### Element objects 114 | 115 | The document contains an internal representation of the markdown document as 116 | parsed by kramdown. Kramdown's representation of the document is as a tree of 117 | 'element' objects. The following is a quick summary of those objects: 118 | 119 | * element.type - a symbol denoting the type of the element, such as `:li`, 120 | `:p`, `:text` 121 | * element.value - the value of the element. Note that most block level 122 | elements such as paragraphs don't have any value themselves, but have child 123 | text elements containing their contents instead. 124 | * element.children - A list of the element's child elements. 125 | * element.options - A hash containing: 126 | * `:location` - line number of element 127 | * `:element_level` - A value filled in by markdownlint to denote the nesting 128 | level of the element, i.e. how deep in the tree is it. 129 | * Other options that are element type specific. 130 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Markdownlint 2 | 3 | We're glad you want to contribute markdownlint! This document will help answer 4 | common questions you may have during your first contribution. Please, try to 5 | follow these guidelines when you do so. 6 | 7 | ## Issue Reporting 8 | 9 | Not every contribution comes in the form of code. Submitting, confirming, and 10 | triaging issues is an important task for any project. We use GitHub to track 11 | all project issues. If you discover bugs, have ideas for improvements or new 12 | features, please start by [opening an 13 | issue](https://github.com/markdownlint/markdownlint/issues) on this repository. 14 | We use issues to centralize the discussion and agree on a plan of action before 15 | spending time and effort writing code that might not get used. 16 | 17 | ### Submitting An Issue 18 | 19 | * Check that the issue has not already been reported 20 | * Check that the issue has not already been fixed in the latest code 21 | (a.k.a. `main`) 22 | * Select the appropriate issue type and open an issue with a descriptive title 23 | * Be clear, concise, and precise using grammatically correct, complete sentences 24 | in your summary of the problem 25 | * Include the output of `mdl -V` or `mdl --version` 26 | * Include any relevant code in the issue 27 | 28 | ## Code Contributions 29 | 30 | Markdownlint follows a [forking 31 | workflow](https://guides.github.com/activities/forking/), and we have a simple 32 | process for contributions: 33 | 34 | 1. Open an issue on the [project 35 | repository](https://github.com/markdownlint/markdownlint/issues), if 36 | appropriate 37 | 1. If you're adding or making changes to rules, read the [Development 38 | docs](#local-development) 39 | 1. Follow the [forking workflow](https://guides.github.com/activities/forking/) 40 | steps: 41 | 1. Fork the project ( ) 42 | 1. Create your feature branch (`git checkout -b my-new-feature`) 43 | 1. Commit your changes (`git commit -am 'Add some feature'`) 44 | 1. Sign your changes (`git commit --amend -s`) 45 | 1. Push to the branch (`git push origin my-new-feature`) 46 | 1. Create a [GitHub Pull 47 | Request](https://help.github.com/articles/about-pull-requests/) for your 48 | change, following all [pull request 49 | requirements](#pull-request-requirements) and any instructions in the pull 50 | request template 51 | 1. Participate in a [Code Review](#code-review-process) with the project 52 | maintainers on the pull request 53 | 54 | ### Your First Code Contribution 55 | 56 | Unsure where to begin contributing to Markdownlint? You can start by looking 57 | through these beginner and help-wanted issues: 58 | 59 | * [Beginner issues](https://github.com/markdownlint/markdownlint/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+sort%3Acomments-desc) 60 | * Issues which should only require a few lines of code, and a test or two. 61 | * [Help wanted issues](https://github.com/markdownlint/markdownlint/issues?q=is%3aissue+is%3aopen+label%3a%22help+wanted%22+sort%3Acomments-desc) 62 | * Issues which should be a bit more involved than beginner issues. Both 63 | issue lists are sorted by total number of comments. While not perfect, 64 | number of comments is a reasonable proxy for impact a given change will 65 | have. 66 | 67 | ### Pull Request Requirements 68 | 69 | Markdownlint strives to ensure high quality for the project. In order to 70 | promote this, we require that all pull requests to meet these specifications: 71 | 72 | * **Tests:** To ensure high quality code and protect against future 73 | regressions, we require tests for all new/changed functionality in 74 | Markdownlint. Test positive and negative scenarios, try to break the new code 75 | now. 76 | * **Green CI Tests:** We use [GitHub 77 | Actions](https://github.com/markdownlint/markdownlint/actions) to test all pull 78 | requests. We require these test runs to succeed on every pull request before 79 | being merged. 80 | * **Signed Commits:** With [Developer Certificates of 81 | Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin), we 82 | want to ascertain your contribution rightfully may enter the project. GitHub 83 | hence [automatically checks](https://github.com/apps/dco) your PR in line as 84 | one prerequisite for a merge. For additional context, see e.g. the discussion 85 | on 86 | [stackoverflow](https://stackoverflow.com/questions/1962094/what-is-the-sign-off-feature-in-git-for) 87 | or the reasoning on 88 | [ProgressChef](https://www.chef.io/blog/introducing-developer-certificate-of-origin). 89 | 90 | ### Code Review Process 91 | 92 | Code review takes place in GitHub pull requests. See [this 93 | article](https://help.github.com/articles/about-pull-requests/) if you're not 94 | familiar with GitHub Pull Requests. 95 | 96 | Once you open a pull request, project maintainers will review your code and 97 | respond to your pull request with any feedback they might have. The process at 98 | this point is as follows: 99 | 100 | 1. A review is required from at least one of the project maintainers. See the 101 | maintainers document for Markdownlint project at 102 | . 103 | 1. Your change will be merged into the project's `main` branch, and all 104 | [commits will be 105 | squashed](https://help.github.com/en/articles/about-pull-request-merges#squash-and-merge-your-pull-request-commits) 106 | during the merge. 107 | 108 | If you would like to learn about when your code will be available in a release 109 | of Markdownlint, read more about [Markdownlint Release 110 | Cycles](#release-cycles). 111 | 112 | ## Releases 113 | 114 | We release Markdownlint as a gem to [Rubygems](https://rubygems.org/gems/mdl) 115 | and maintain a [Dockerfile](https://hub.docker.com/r/mivok/markdownlint) 116 | 117 | Markdownlint follows the [Semantic Versioning](https://semver.org/) standard. 118 | Our standard version numbers look like `X.Y.Z` and translates to: 119 | 120 | * `X` is a major release: has changes that may be incompatible with prior major 121 | releases 122 | * `Y` is a minor release: adds new functionality and bug fixes in a backwards 123 | compatible manner 124 | * `Z` is a patch release: adds backwards compatible bug fixes 125 | 126 | *Exception: Versions < 1.0 may introduce backwards-incompatible changes in a 127 | minor version* 128 | -------------------------------------------------------------------------------- /lib/mdl/cli.rb: -------------------------------------------------------------------------------- 1 | require 'mixlib/cli' 2 | require 'pathname' 3 | 4 | module MarkdownLint 5 | # Our Mixlib::CLI class 6 | class CLI 7 | include Mixlib::CLI 8 | 9 | CONFIG_FILE = '.mdlrc'.freeze 10 | 11 | banner "Usage: #{File.basename($PROGRAM_NAME)} [options] [FILE.md|DIR ...]" 12 | 13 | option :show_aliases, 14 | :short => '-a', 15 | :long => '--[no-]show-aliases', 16 | :description => 17 | 'Show rule alias instead of rule ID when viewing rules', 18 | :boolean => true 19 | 20 | option :config_file, 21 | :short => '-c', 22 | :long => '--config FILE', 23 | :description => 'The configuration file to use', 24 | :default => CONFIG_FILE.to_s 25 | 26 | option :verbose, 27 | :short => '-v', 28 | :long => '--[no-]verbose', 29 | :description => 'Increase verbosity', 30 | :boolean => true 31 | 32 | option :ignore_front_matter, 33 | :short => '-i', 34 | :long => '--[no-]ignore-front-matter', 35 | :boolean => true, 36 | :description => 'Ignore YAML front matter' 37 | 38 | option :show_kramdown_warnings, 39 | :short => '-w', 40 | :long => '--[no-]warnings', 41 | :description => 'Show kramdown warnings', 42 | :boolean => true 43 | 44 | option :tags, 45 | :short => '-t', 46 | :long => '--tags TAG1,TAG2', 47 | :description => 'Only process rules with these tags', 48 | :proc => proc { |v| toggle_list(v, true) } 49 | 50 | option :rules, 51 | :short => '-r', 52 | :long => '--rules RULE1,RULE2', 53 | :description => 'Only process these rules', 54 | :proc => proc { |v| toggle_list(v) } 55 | 56 | option :style, 57 | :short => '-s', 58 | :long => '--style STYLE', 59 | :description => 'Load the given style' 60 | 61 | option :list_rules, 62 | :short => '-l', 63 | :long => '--list-rules', 64 | :boolean => true, 65 | :description => "Don't process any files, just list enabled rules" 66 | 67 | option :git_recurse, 68 | :short => '-g', 69 | :long => '--git-recurse', 70 | :boolean => true, 71 | :description => 72 | 'Only process files known to git when given a directory' 73 | 74 | option :rulesets, 75 | :short => '-u', 76 | :long => '--rulesets RULESET1,RULESET2', 77 | :proc => proc { |v| v.split(',') }, 78 | :description => 'Specify additional ruleset files to load' 79 | 80 | option :skip_default_ruleset, 81 | :short => '-d', 82 | :long => '--skip-default-ruleset', 83 | :boolean => true, 84 | :description => "Don't load the default markdownlint ruleset" 85 | 86 | option :help, 87 | :on => :tail, 88 | :short => '-h', 89 | :long => '--help', 90 | :description => 'Show this message', 91 | :boolean => true, 92 | :show_options => true, 93 | :exit => 0 94 | 95 | option :version, 96 | :on => :tail, 97 | :short => '-V', 98 | :long => '--version', 99 | :description => 'Show version', 100 | :boolean => true, 101 | :proc => proc { puts MarkdownLint::VERSION }, 102 | :exit => 0 103 | 104 | option :json, 105 | :short => '-j', 106 | :long => '--json', 107 | :description => 'JSON output', 108 | :boolean => true 109 | 110 | option :sarif, 111 | :short => '-S', 112 | :long => '--sarif', 113 | :description => 'SARIF output', 114 | :boolean => true 115 | 116 | def run(argv = ARGV) 117 | parse_options(argv) 118 | 119 | # Load the config file if it's present 120 | filename = CLI.probe_config_file(config[:config_file]) 121 | 122 | # Only fall back to ~/.mdlrc if we are using the default value for -c 123 | if filename.nil? && (config[:config_file] == CONFIG_FILE) 124 | filename = File.expand_path("~/#{CONFIG_FILE}") 125 | end 126 | 127 | if !filename.nil? && File.exist?(filename) 128 | MarkdownLint::Config.from_file(filename.to_s) 129 | puts "Loaded config from #{filename}" if config[:verbose] 130 | end 131 | 132 | # Put values in the config file 133 | MarkdownLint::Config.merge!(config) 134 | 135 | # Set the correct format for any rules/tags configuration loaded from 136 | # the config file. Ideally this would probably be done as part of the 137 | # config class itself rather than here. 138 | unless MarkdownLint::Config[:rules].nil? 139 | MarkdownLint::Config[:rules] = CLI.toggle_list( 140 | MarkdownLint::Config[:rules], 141 | ) 142 | end 143 | unless MarkdownLint::Config[:tags].nil? 144 | MarkdownLint::Config[:tags] = CLI.toggle_list( 145 | MarkdownLint::Config[:tags], true 146 | ) 147 | end 148 | 149 | # Read from stdin if we didn't provide a filename 150 | cli_arguments << '-' if cli_arguments.empty? && !config[:list_rules] 151 | end 152 | 153 | def self.toggle_list(parts, to_sym = false) 154 | parts = parts.split(',') if parts.instance_of?(String) 155 | if parts.instance_of?(Array) 156 | inc = parts.reject { |p| p.start_with?('~') } 157 | exc = parts.select { |p| p.start_with?('~') }.map { |p| p[1..] } 158 | if to_sym 159 | inc.map!(&:to_sym) 160 | exc.map!(&:to_sym) 161 | end 162 | { :include => inc, :exclude => exc } 163 | else 164 | # We already converted the string into a list of include/exclude 165 | # pairs, so just return as is 166 | parts 167 | end 168 | end 169 | 170 | def self.probe_config_file(path) 171 | expanded_path = File.expand_path(path) 172 | return expanded_path if File.exist?(expanded_path) 173 | 174 | # Look for a file up from the working dir 175 | Pathname.new(expanded_path).ascend do |p| 176 | next unless p.directory? 177 | 178 | config_file = p.join(CONFIG_FILE) 179 | return config_file if File.exist?(config_file) 180 | end 181 | nil 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/mdl/doc.rb: -------------------------------------------------------------------------------- 1 | require 'kramdown' 2 | require_relative 'kramdown_parser' 3 | 4 | module MarkdownLint 5 | ## 6 | # Representation of the markdown document passed to rule checks 7 | class Doc 8 | ## 9 | # A list of raw markdown source lines. Note that the list is 0-indexed, 10 | # while line numbers in the parsed source are 1-indexed, so you need to 11 | # subtract 1 from a line number to get the correct line. The element_line* 12 | # methods take care of this for you. 13 | 14 | attr_reader :lines, :parsed, :elements, :offset 15 | 16 | ## 17 | # A Kramdown::Document object containing the parsed markdown document. 18 | 19 | ## 20 | # A list of top level Kramdown::Element objects from the parsed document. 21 | 22 | ## 23 | # The line number offset which is greater than zero when the 24 | # markdown file contains YAML front matter that should be ignored. 25 | 26 | ## 27 | # Create a new document given a string containing the markdown source 28 | 29 | def initialize(text, ignore_front_matter = false) 30 | regex = /^---\n(.*?)---\n\n?/m 31 | if ignore_front_matter && regex.match(text) 32 | @offset = regex.match(text).to_s.split("\n").length 33 | text.sub!(regex, '') 34 | else 35 | @offset = 0 36 | end 37 | # The -1 is to cause split to preserve an extra entry in the array so we 38 | # can tell if there's a final newline in the file or not. 39 | @lines = text.split(/\R/, -1) 40 | @parsed = Kramdown::Document.new(text, :input => 'MarkdownLint') 41 | @elements = @parsed.root.children 42 | add_annotations(@elements) 43 | end 44 | 45 | ## 46 | # Alternate 'constructor' passing in a filename 47 | 48 | def self.new_from_file(filename, ignore_front_matter = false) 49 | if filename == '-' 50 | new($stdin.read, ignore_front_matter) 51 | else 52 | new(File.read(filename, :encoding => 'UTF-8'), ignore_front_matter) 53 | end 54 | end 55 | 56 | ## 57 | # Find all elements of a given type, returning their options hash. The 58 | # options hash has most of the useful data about an element and often you 59 | # can just use this in your rules. 60 | # 61 | # # Returns [ { :location => 1, :element_level => 2 }, ... ] 62 | # elements = find_type(:li) 63 | # 64 | # If +nested+ is set to false, this returns only top level elements of a 65 | # given type. 66 | 67 | def find_type(type, nested = true) 68 | find_type_elements(type, nested).map(&:options) 69 | end 70 | 71 | ## 72 | # Find all elements of a given type, returning a list of the element 73 | # objects themselves. 74 | # 75 | # Instead of a single type, a list of types can be provided instead to 76 | # find all types. 77 | # 78 | # If +nested+ is set to false, this returns only top level elements of a 79 | # given type. 80 | 81 | def find_type_elements(type, nested = true, elements = @elements) 82 | results = [] 83 | type = [type] if type.instance_of?(Symbol) 84 | elements.each do |e| 85 | results.push(e) if type.include?(e.type) 86 | if nested && !e.children.empty? 87 | results.concat(find_type_elements(type, nested, e.children)) 88 | end 89 | end 90 | results 91 | end 92 | 93 | ## 94 | # A variation on find_type_elements that allows you to skip drilling down 95 | # into children of specific element types. 96 | # 97 | # Instead of a single type, a list of types can be provided instead to 98 | # find all types. 99 | # 100 | # Unlike find_type_elements, this method will always search for nested 101 | # elements, and skip the element types given to nested_except. 102 | 103 | def find_type_elements_except( 104 | type, nested_except = [], elements = @elements 105 | ) 106 | results = [] 107 | type = [type] if type.instance_of?(Symbol) 108 | nested_except = [nested_except] if nested_except.instance_of?(Symbol) 109 | elements.each do |e| 110 | results.push(e) if type.include?(e.type) 111 | next if nested_except.include?(e.type) || e.children.empty? 112 | 113 | results.concat( 114 | find_type_elements_except(type, nested_except, e.children), 115 | ) 116 | end 117 | results 118 | end 119 | 120 | ## 121 | # Returns the line number a given element is located on in the source 122 | # file. You can pass in either an element object or an options hash here. 123 | 124 | def element_linenumber(element) 125 | element = element.options if element.is_a?(Kramdown::Element) 126 | element[:location] 127 | end 128 | 129 | ## 130 | # Returns the actual source line for a given element. You can pass in an 131 | # element object or an options hash here. This is useful if you need to 132 | # examine the source line directly for your rule to make use of 133 | # information that isn't present in the parsed document. 134 | 135 | def element_line(element) 136 | @lines[element_linenumber(element) - 1] 137 | end 138 | 139 | ## 140 | # Returns a list of line numbers for all elements passed in. You can pass 141 | # in a list of element objects or a list of options hashes here. 142 | 143 | def element_linenumbers(elements) 144 | elements.map { |e| element_linenumber(e) } 145 | end 146 | 147 | ## 148 | # Returns the actual source lines for a list of elements. You can pass in 149 | # a list of elements objects or a list of options hashes here. 150 | 151 | def element_lines(elements) 152 | elements.map { |e| element_line(e) } 153 | end 154 | 155 | ## 156 | # Returns the header 'style' - :atx (hashes at the beginning), :atx_closed 157 | # (atx header style, but with hashes at the end of the line also), :setext 158 | # (underlined). You can pass in the element object or an options hash 159 | # here. 160 | 161 | def header_style(header) 162 | if header.type != :header 163 | raise 'header_style called with non-header element' 164 | end 165 | 166 | line = element_line(header) 167 | if line.start_with?('#') 168 | if line.strip.end_with?('#') 169 | :atx_closed 170 | else 171 | :atx 172 | end 173 | else 174 | :setext 175 | end 176 | end 177 | 178 | ## 179 | # Returns the list style for a list: :asterisk, :plus, :dash, :ordered or 180 | # :ordered_paren depending on which symbol is used to denote the list 181 | # item. You can pass in either the element itself or an options hash here. 182 | 183 | def list_style(item) 184 | raise 'list_style called with non-list element' if item.type != :li 185 | 186 | line = element_line(item).strip.gsub(/^>\s+/, '') 187 | if line.start_with?('*') 188 | :asterisk 189 | elsif line.start_with?('+') 190 | :plus 191 | elsif line.start_with?('-') 192 | :dash 193 | elsif line.match('[0-9]+\.') 194 | :ordered 195 | elsif line.match('[0-9]+\)') 196 | :ordered_paren 197 | else 198 | :unknown 199 | end 200 | end 201 | 202 | ## 203 | # Returns how much a given line is indented. Hard tabs are treated as an 204 | # indent of 8 spaces. You need to pass in the raw string here. 205 | 206 | def indent_for(line) 207 | line.match(/^\s*/)[0].gsub("\t", ' ' * 8).length 208 | end 209 | 210 | ## 211 | # Returns line numbers for lines that match the given regular expression 212 | 213 | def matching_lines(regex) 214 | @lines.each_with_index.select { |text, _linenum| regex.match(text) } 215 | .map do |i| 216 | i[1] + 1 217 | end 218 | end 219 | 220 | ## 221 | # Returns line numbers for lines that match the given regular expression. 222 | # Only considers text inside of 'text' elements (i.e. regular markdown 223 | # text and not code/links or other elements). 224 | def matching_text_element_lines(regex, exclude_nested = [:a]) 225 | matches = [] 226 | find_type_elements_except(:text, exclude_nested).each do |e| 227 | first_line = e.options[:location] 228 | # We'll error out if kramdown doesn't have location information for 229 | # the current element. It's better to just not match in these cases 230 | # rather than crash. 231 | next if first_line.nil? 232 | 233 | lines = e.value.split("\n") 234 | lines.each_with_index do |l, i| 235 | matches << (first_line + i) if regex.match(l) 236 | end 237 | end 238 | matches 239 | end 240 | 241 | ## 242 | # Extracts the text from an element whose children consist of text 243 | # elements and other things 244 | 245 | def extract_text(element, prefix = '', restore_whitespace = true) 246 | quotes = { 247 | :rdquo => '"', 248 | :ldquo => '"', 249 | :lsquo => "'", 250 | :rsquo => "'", 251 | } 252 | # If anything goes amiss here, e.g. unknown type, then nil will be 253 | # returned and we'll just not catch that part of the text, which seems 254 | # like a sensible failure mode. 255 | lines = element.children.map do |e| 256 | if e.type == :text 257 | e.value 258 | elsif %i{strong em p codespan}.include?(e.type) 259 | extract_text(e, prefix, restore_whitespace).join("\n") 260 | elsif e.type == :smart_quote 261 | quotes[e.value] 262 | end 263 | end.join.split("\n") 264 | # Text blocks have whitespace stripped, so we need to add it back in at 265 | # the beginning. Because this might be in something like a blockquote, 266 | # we optionally strip off a prefix given to the function. 267 | lines[0] = element_line(element).sub(prefix, '') if restore_whitespace 268 | lines 269 | end 270 | 271 | ## 272 | # Returns the element as plaintext 273 | 274 | def extract_as_text(element) 275 | quotes = { 276 | :rdquo => '"', 277 | :ldquo => '"', 278 | :lsquo => "'", 279 | :rsquo => "'", 280 | } 281 | # If anything goes amiss here, e.g. unknown type, then nil will be 282 | # returned and we'll just not catch that part of the text, which seems 283 | # like a sensible failure mode. 284 | element.children.map do |e| 285 | if e.type == :text || e.type == :codespan 286 | e.value 287 | elsif %i{strong em p a}.include?(e.type) 288 | extract_as_text(e).join("\n") 289 | elsif e.type == :smart_quote 290 | quotes[e.value] 291 | end 292 | end.join.split("\n") 293 | end 294 | 295 | private 296 | 297 | ## 298 | # Adds a 'level' and 'parent' option to all elements to show how nested they 299 | # are 300 | 301 | def add_annotations(elements, level = 1, parent = nil) 302 | elements.each do |e| 303 | e.options[:element_level] = level 304 | e.options[:parent] = parent 305 | add_annotations(e.children, level + 1, e) 306 | end 307 | end 308 | end 309 | end 310 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | 5 | ### Fixed 6 | 7 | * Fix Markdown lint version in SARIF output test [#469](https://github.com/markdownlint/markdownlint/pull/469) 8 | 9 | ## [v0.13.0] (2023-10-01) 10 | 11 | ### Rules Added 12 | 13 | * MD055 - Tables: Each row must start and end with a `|` [#464](https://github.com/markdownlint/markdownlint/pull/464) 14 | * MD056 - Tables: Number of columns is the same for all 15 | rows [#464](https://github.com/markdownlint/markdownlint/pull/464) 16 | * MD057 - Tables: In the second row every column must have at least `---`, 17 | possibly surrounded with alignment `:` chars [#464](https://github.com/markdownlint/markdownlint/pull/464) 18 | 19 | ### Added 20 | 21 | * Add SARIF output [#459](https://github.com/markdownlint/markdownlint/pull/459) 22 | * Document DCO process better [#448](https://github.com/markdownlint/markdownlint/pull/448) 23 | and [#449](https://github.com/markdownlint/markdownlint/pull/449) 24 | 25 | ### Changed 26 | 27 | * MD009 - Allow exactly 2 trailing spaces by default [#452](https://github.com/markdownlint/markdownlint/pull/452) 28 | * MD033 - Add `allowed_elements` parameter [#450](https://github.com/markdownlint/markdownlint/pull/450) 29 | * Updated build instructions [#431](https://github.com/markdownlint/markdownlint/pull/431) 30 | 31 | ### Fixed 32 | 33 | * MD027 - handle anchor elements correctly [#463](https://github.com/markdownlint/markdownlint/pull/463) 34 | * Fix examples for RULES.md for MD007 [#462](https://github.com/markdownlint/markdownlint/pull/462) 35 | * Fix links to use https instead of http [#447](https://github.com/markdownlint/markdownlint/pull/447) 36 | * Make RULES.md comply with our own rules [#439](https://github.com/markdownlint/markdownlint/pull/439) 37 | * Fix docker builds [#429](https://github.com/markdownlint/markdownlint/pull/429) 38 | 39 | ## [v0.12.0] (2022-10-17) 40 | 41 | ### Rules Added 42 | 43 | * MD047 - File should end with a blank line 44 | 45 | ### Added 46 | 47 | * New 'docs' method on rules to provide a URL and longer description 48 | * `docker_image`-based pre-commit 49 | 50 | ### Changed 51 | 52 | * Changed the default for MD007 to 3 spaces to match minimum spaces for ordered lists 53 | * Added option `:ignore_code_blocks` to rule MD010. If set to true, hard tabs in 54 | code blocks will be ignored. 55 | * Added option `:ignore_code_blocks` to rule MD013. If set to true, hard tabs in 56 | code blocks will be ignored. The option `:code_blocks` has been marked as 57 | deprecated in the documentation. If `:code_blocks` is set to false in the 58 | configuration, a deprecation warning is printed. 59 | * Improved documentation on custom rules and rulesets 60 | * Handle non-printable characters gracefully 61 | * Support configurable sublist styles for MD004 62 | 63 | ### Fixed 64 | 65 | * Fixed directory argument with `--git-recurse` 66 | * Preserve empty lines at the end of a file 67 | 68 | ## [v0.11.0] (2020-08-22) 69 | 70 | ### Fixed 71 | 72 | * Fixed crash when using `-g` 73 | * Fixed missing dependencies in docker image 74 | 75 | ## [v0.10.0] (2020-08-08) 76 | 77 | ### Added 78 | 79 | * More examples of mdlrc and style files 80 | * Added CI for Rubocop and Markdownlint on our own repo 81 | 82 | ### Fixed 83 | 84 | * Update Dockerfile to work with modern mdl 85 | * Update minimum version of kramdown for security advisory 86 | * Update minimum version of rubocop for security advisory 87 | 88 | ## [v0.9.0] (2020-02-21) 89 | 90 | ### Changed 91 | 92 | * Better error messages on missing styles [#302](https://github.com/markdownlint/markdownlint/pull/302) 93 | * Use require_relative to speed up requires [#297](https://github.com/markdownlint/markdownlint/pull/297) 94 | * Bumped alpine version in the Dockerfile [#155](https://github.com/markdownlint/markdownlint/pull/155) 95 | 96 | ### Fixed 97 | 98 | * Fix crash in --json [#286](https://github.com/markdownlint/markdownlint/pull/286) 99 | * Handle codeblocks that are nested [#293](https://github.com/markdownlint/markdownlint/pull/293) 100 | * Fix handling of blockquoted list items [#284](https://github.com/markdownlint/markdownlint/pull/284) 101 | 102 | ## [v0.8.0] (2019-11-08) 103 | 104 | ### Changed 105 | 106 | * Don't ship test / example files in the gem artifact [#282](https://github.com/markdownlint/markdownlint/pull/282) 107 | 108 | ### Fixed 109 | 110 | * Handle newlines on Windows better [#238](https://github.com/markdownlint/markdownlint/pull/238) 111 | 112 | ## [v0.7.0] (2019-10-24) 113 | 114 | ### Added 115 | 116 | * Pull request and issue templates for users and contributors 117 | * Move to kramdown 2 118 | * Handle Kramdown TOC 119 | * Loosen various dependencies and move minimum ruby version to 2.4 120 | 121 | ## [v0.6.0] (2019-10-19) 122 | 123 | ### Added 124 | 125 | * There are now CONTRIBUTING.md and MAINTAINERS.md docs 126 | * There is now a `.pre-commit-hooks.yaml` for those who want to use pre-commit.com 127 | 128 | ### Changed 129 | 130 | * Ignore blank line after front matter [#248](https://github.com/markdownlint/markdownlint/pull/248) 131 | * Only import JSON when necessary [#231](https://github.com/markdownlint/markdownlint/pull/231) 132 | * Use newere mixlib-cli [#265](https://github.com/markdownlint/markdownlint/pull/265) 133 | * Nicer error message when activating nonexistent rule [#246](https://github.com/markdownlint/markdownlint/pull/246) 134 | * Fix documentation on `ignore_front_matter` [#241](https://github.com/markdownlint/markdownlint/pull/241) 135 | * Update docs to use "MY" prefix for custom rule example [#245](https://github.com/markdownlint/markdownlint/pull/245) 136 | * Fix crash in MD039 when the link text is empty [#256](https://github.com/markdownlint/markdownlint/pull/256) 137 | * Reference Node.js markdownlint [#229](https://github.com/markdownlint/markdownlint/pull/229) 138 | * Added table of contents to RULES.md for easier 139 | navigation [#232](https://github.com/markdownlint/markdownlint/pull/232) 140 | * Fix typos in MD046 docs [#219](https://github.com/markdownlint/markdownlint/pull/219) 141 | * Fixed MD036 crash [#222](https://github.com/markdownlint/markdownlint/pull/222) 142 | 143 | ### Removed 144 | 145 | ## [v0.5.0] (2018-07-01) 146 | 147 | ### Added 148 | 149 | * Add md042 to enforce code block style 150 | * JSON formatter/output 151 | 152 | ### Changed 153 | 154 | * Allow different nesting on headers duplication check [#200](https://github.com/markdownlint/markdownlint/pull/200) 155 | * MD036 - Ignore multi-line emphasized paragraphs, and emphasized paragraphs 156 | that end in punctuation [#140](https://github.com/markdownlint/markdownlint/pull/140) 157 | 158 | ### Fixed 159 | 160 | * Fix issue numbers false positives [#168](https://github.com/markdownlint/markdownlint/pull/168) 161 | * Lint MD039 checking for nodes inside link text [#102](https://github.com/markdownlint/markdownlint/issues/102) 162 | 163 | ## [v0.4.0] (2016-08-22) 164 | 165 | ### Added 166 | 167 | * Ignore yaml front matter option [#130](https://github.com/markdownlint/markdownlint/pull/130) 168 | and [#143](https://github.com/markdownlint/markdownlint/pull/143) 169 | 170 | ### Changed 171 | 172 | * Allow top level header rules (MD002, MD025, MD041) to be 173 | configurable [#142](https://github.com/markdownlint/markdownlint/pull/142) 174 | 175 | ### Fixed 176 | 177 | * Read UTF-8 files correctly even if locale is set to 178 | C [#135](https://github.com/markdownlint/markdownlint/pull/135), 179 | [#146](https://github.com/markdownlint/markdownlint/pull/146), 180 | [#147](https://github.com/markdownlint/markdownlint/pull/147) 181 | and [#148](https://github.com/markdownlint/markdownlint/pull/148) 182 | * Fix issues loading .mdlrc 183 | file [#126](https://github.com/markdownlint/markdownlint/pull/126), 184 | [#129](https://github.com/markdownlint/markdownlint/pull/129), 185 | [#133](https://github.com/markdownlint/markdownlint/pull/133) 186 | and [#148](https://github.com/markdownlint/markdownlint/pull/148) 187 | * Fix erroneous triggering of MD022/MD023 in some cases [#144](https://github.com/markdownlint/markdownlint/pull/144) 188 | * Detect codeblock lines correctly when ignoring them [#141](https://github.com/markdownlint/markdownlint/pull/141) 189 | 190 | ## [v0.3.1] (2016-03-20) 191 | 192 | ### Fixed 193 | 194 | * Fix error on starting mdl 195 | 196 | ## [v0.3.0] (2016-03-19) 197 | 198 | ### Rules added 199 | 200 | * MD041 - First line in file should be a top level header 201 | 202 | ### Added 203 | 204 | * You can now load your own custom rules with the `-u` option. See 205 | [rules.rb](https://github.com/markdownlint/markdownlint/blob/main/lib/mdl/rules.rb) 206 | for an example of what a rules file looks like. Use the `-d` option if you 207 | don't want to load markdownlint's default ruleset. 208 | * You can now refer to rules by human-readable/writable aliases, such as 209 | 'ul-style' instead of 'MD004'. See RULES.md for a list of the rule aliases. 210 | You can pass the `-a` option to display rule aliases instead of MDxxx rule 211 | IDs. 212 | 213 | ### Changed 214 | 215 | * MD003 - An additional header style, setext_with_atx, was added to require 216 | setext style headers for levels 1 and 2, but allow atx style headers for 217 | levels 3 and above (i.e. header levels that can't be expressed with setext 218 | style headers) 219 | * MD013 - You now have the option to exclude code blocks and tables from the 220 | line length limit check. 221 | 222 | ### Fixed 223 | 224 | * Crash with MD034 and pipe character [#93](https://github.com/markdownlint/markdownlint/pull/93) 225 | and [#97](https://github.com/markdownlint/markdownlint/pull/97) 226 | * MD031 failed on nested code blocks [#100](https://github.com/markdownlint/markdownlint/pull/100) 227 | and [#109](https://github.com/markdownlint/markdownlint/pull/109) 228 | * MD037 crashes on
  • with underscores [#83](https://github.com/markdownlint/markdownlint/pull/83) 229 | * Regression introduced in v0.2.1 - ignoring rules/tags on the command line 230 | caused a crash [#108](https://github.com/markdownlint/markdownlint/pull/108) 231 | * MD027 false positive when line starts with a backtick [#105](https://github.com/markdownlint/markdownlint/pull/105) 232 | 233 | ### Merged pull requests 234 | 235 | * [Add support for nested code fences to MD031/MD032 - David 236 | Anson](https://github.com/markdownlint/markdownlint/pull/109) 237 | * [Add missing word to description of MD035 in RULES.md - David 238 | Anson](https://github.com/markdownlint/markdownlint/pull/86) 239 | * [Probe for .mdlrc in current and parent directories - Loic 240 | Nageleisen](https://github.com/markdownlint/markdownlint/pull/111) 241 | * [MD013: allow excluding code blocks and tables - Loic 242 | Nageleisen](https://github.com/markdownlint/markdownlint/pull/112) 243 | 244 | ## [v0.2.1] (2015-04-13) 245 | 246 | ### Fixed 247 | 248 | * Incorrect parsing of rules/tags specification in .mdlrc [#81](https://github.com/markdownlint/markdownlint/pull/81) 249 | * Exception on image links with MD039 [#82](https://github.com/markdownlint/markdownlint/pull/82) 250 | * MD037 flags on two words beginning with underscores on the same 251 | line [#83](https://github.com/markdownlint/markdownlint/pull/83) 252 | 253 | ### Known issues 254 | 255 | * Exception on some lines with raw html list items in them [#83](https://github.com/markdownlint/markdownlint/pull/83) 256 | 257 | ## [v0.2.0] (2015-04-13) 258 | 259 | ### Rules added 260 | 261 | * MD033 - Inline HTML 262 | * MD034 - Bare URL used 263 | * MD035 - Horizontal rule style 264 | * MD036 - Emphasis used instead of a header 265 | * MD037 - Spaces inside emphasis markers 266 | * MD038 - Spaces inside code span elements 267 | * MD039 - Spaces inside link text 268 | * MD040 - Fenced code blocks should have a language specified 269 | 270 | ## Added 271 | 272 | * Trailing spaces rule should allow an excemption for deliberate
    273 | insertion. 274 | * Rules can be excluded in .mdlrc and on the command line by specifying a rule 275 | as ~MD000. 276 | 277 | ### Merged pull requests 278 | 279 | * [Add parameter (value and default) information to rule documentation. - David Anson](https://github.com/markdownlint/markdownlint/pull/76) 280 | 281 | ## [v0.1.0] (2015-02-22) 282 | 283 | ### Rules added 284 | 285 | * MD031 - Fenced code blocks should be surrounded by blank lines 286 | * MD032 - Lists should be surrounded by blank lines 287 | 288 | ### Fixed 289 | 290 | * MD014 triggers when it shouldn't 291 | 292 | ### Merged pull requests 293 | 294 | * [MD032 - Lists should be surrounded by blank lines - David Anson](https://github.com/markdownlint/markdownlint/pull/70) 295 | * [MD031 - Fenced code blocks should be surrounded by blank lines - David Anson](https://github.com/markdownlint/markdownlint/pull/68) 296 | * [Clarify how to specify your own style - mjankowski](https://github.com/markdownlint/markdownlint/pull/65) 297 | * [Use single quotes to prevent early escaping - highb](https://github.com/markdownlint/markdownlint/pull/64) 298 | 299 | ## [v0.0.1] (2014-09-07) 300 | 301 | ### Rules added 302 | 303 | * MD001 - Header levels should only increment by one level at a time 304 | * MD002 - First header should be a h1 header 305 | * MD003 - Header style 306 | * MD004 - Unordered list style 307 | * MD005 - Inconsistent indentation for list items at the same level 308 | * MD006 - Consider starting bulleted lists at the beginning of the line 309 | * MD007 - Unordered list indentation 310 | * MD009 - Trailing spaces 311 | * MD010 - Hard tabs 312 | * MD011 - Reversed link syntax 313 | * MD012 - Multiple consecutive blank lines 314 | * MD013 - Line length 315 | * MD014 - Dollar signs used before commands without showing output 316 | * MD018 - No space after hash on atx style header 317 | * MD019 - Multiple spaces after hash on atx style header 318 | * MD020 - No space inside hashes on closed atx style header 319 | * MD021 - Multiple spaces inside hashes on closed atx style header 320 | * MD022 - Headers should be surrounded by blank lines 321 | * MD023 - Headers must start at the beginning of the line 322 | * MD024 - Multiple headers with the same content 323 | * MD025 - Multiple top level headers in the same document 324 | * MD026 - Trailing punctuation in header 325 | * MD027 - Multiple spaces after blockquote symbol 326 | * MD028 - Blank line inside blockquote 327 | * MD029 - Ordered list item prefix 328 | * MD030 - Spaces after list markers 329 | 330 | [Unreleased]: https://github.com/markdownlint/markdownlint/tree/main 331 | [v0.13.0]: https://github.com/markdownlint/markdownlint/tree/v0.13.0 332 | [v0.12.0]: https://github.com/markdownlint/markdownlint/tree/v0.12.0 333 | [v0.11.0]: https://github.com/markdownlint/markdownlint/tree/v0.11.0 334 | [v0.10.0]: https://github.com/markdownlint/markdownlint/tree/v0.10.0 335 | [v0.9.0]: https://github.com/markdownlint/markdownlint/tree/v0.9.0 336 | [v0.8.0]: https://github.com/markdownlint/markdownlint/tree/v0.8.0 337 | [v0.7.0]: https://github.com/markdownlint/markdownlint/tree/v0.7.0 338 | [v0.6.0]: https://github.com/markdownlint/markdownlint/tree/v0.6.0 339 | [v0.5.0]: https://github.com/markdownlint/markdownlint/tree/v0.5.0 340 | [v0.4.0]: https://github.com/markdownlint/markdownlint/tree/v0.4.0 341 | [v0.3.1]: https://github.com/markdownlint/markdownlint/tree/v0.3.1 342 | [v0.3.0]: https://github.com/markdownlint/markdownlint/tree/v0.3.0 343 | [v0.2.1]: https://github.com/markdownlint/markdownlint/tree/v0.2.1 344 | [v0.2.0]: https://github.com/markdownlint/markdownlint/tree/v0.2.0 345 | [v0.1.0]: https://github.com/markdownlint/markdownlint/tree/v0.1.0 346 | [v0.0.1]: https://github.com/markdownlint/markdownlint/tree/v0.0.1 347 | -------------------------------------------------------------------------------- /test/test_cli.rb: -------------------------------------------------------------------------------- 1 | require_relative 'setup_tests' 2 | require 'open3' 3 | require 'set' 4 | require 'erb' 5 | require 'fileutils' 6 | require 'json' 7 | 8 | class TestCli < Minitest::Test 9 | def test_help_text 10 | result = run_cli('--help') 11 | assert_match(/Usage: \S+ \[options\]/, result[:stdout]) 12 | assert_equal(0, result[:status]) 13 | end 14 | 15 | def test_json_output 16 | result = run_cli_with_input('-j', "# header\n") 17 | assert_ran_ok(result) 18 | expected = File.read('test/fixtures/output/json/without_matches.json') 19 | assert_equal(expected, result[:stdout]) 20 | end 21 | 22 | def test_json_output_with_matches 23 | result = run_cli_with_input('-j -r MD002', "## header2\n") 24 | assert_equal(1, result[:status]) 25 | assert_equal('', result[:stderr]) 26 | expected = File.read('test/fixtures/output/json/with_matches.json') 27 | assert_equal(expected, result[:stdout]) 28 | end 29 | 30 | def test_sarif_output 31 | result = run_cli_with_input('-S', "# header\n") 32 | assert_ran_ok(result) 33 | sarif_file = File.read('test/fixtures/output/sarif/without_matches.sarif') 34 | expected = ERB.new(sarif_file).result(binding) 35 | assert_equal(expected, result[:stdout]) 36 | end 37 | 38 | def test_sarif_output_with_matches 39 | result = run_cli_with_input('-S -r MD002', "## header2\n") 40 | assert_equal(1, result[:status]) 41 | assert_equal('', result[:stderr]) 42 | sarif_file = File.read('test/fixtures/output/sarif/with_matches.sarif') 43 | expected = ERB.new(sarif_file).result(binding) 44 | assert_equal(expected, result[:stdout]) 45 | end 46 | 47 | def test_default_ruleset_loading 48 | result = run_cli('-l') 49 | assert_ran_ok(result) 50 | assert_rules_enabled(result, ['MD001']) 51 | end 52 | 53 | def test_show_alias_rule_list 54 | result = run_cli('-al') 55 | assert_ran_ok(result) 56 | assert_rules_enabled(result, ['header-increment']) 57 | end 58 | 59 | def test_show_alias_processing_file 60 | result = run_cli_with_input('-a -r MD002', "## header2\n") 61 | assert_equal(1, result[:status]) 62 | assert_equal('', result[:stderr]) 63 | assert_match(/^\(stdin\):1: first-header-h1/, result[:stdout]) 64 | end 65 | 66 | def test_running_on_unicode_input 67 | result = run_cli_with_file_and_ascii_env("## header2 🚀\n") 68 | assert_equal(1, result[:status]) 69 | assert_equal('', result[:stderr]) 70 | assert_match(/MD002 First header should be a top level header/, 71 | result[:stdout]) 72 | end 73 | 74 | def test_skipping_default_ruleset_loading 75 | result = run_cli('-ld') 76 | assert_rules_enabled(result, [], true) 77 | end 78 | 79 | def test_custom_ruleset_loading 80 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 81 | result = run_cli("-ldu #{my_ruleset}") 82 | assert_rules_enabled(result, ['MY001'], true) 83 | assert_ran_ok(result) 84 | end 85 | 86 | def test_show_alias_rule_without_alias 87 | # Tests that when -a is given, but the rule doesn't have an alias, it 88 | # prints the rule ID instead. 89 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 90 | result = run_cli("-ladu #{my_ruleset}") 91 | assert_rules_enabled(result, ['MY001'], true) 92 | assert_ran_ok(result) 93 | end 94 | 95 | def test_custom_ruleset_processing_success 96 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 97 | result = run_cli_with_input("-du #{my_ruleset}", 'Hello World') 98 | assert_equal('', result[:stdout]) 99 | assert_ran_ok(result) 100 | end 101 | 102 | def test_custom_ruleset_processing_failure 103 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 104 | result = run_cli_with_input("-du #{my_ruleset}", 'Goodbye world') 105 | assert_equal(1, result[:status]) 106 | assert_match(/^\(stdin\):1: MY001/, result[:stdout]) 107 | assert_equal('', result[:stderr]) 108 | end 109 | 110 | def test_custom_ruleset_processing_failure_with_show_alias 111 | # The custom rule doesn't have an alias, so the output should be identical 112 | # to that without show_alias enabled. 113 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 114 | result = run_cli_with_input("-dau #{my_ruleset}", 'Goodbye world') 115 | assert_equal(1, result[:status]) 116 | assert_match(/^\(stdin\):1: MY001/, result[:stdout]) 117 | assert_equal('', result[:stderr]) 118 | end 119 | 120 | def test_custom_ruleset_loading_with_default 121 | my_ruleset = File.expand_path('fixtures/my_ruleset.rb', __dir__) 122 | result = run_cli("-lu #{my_ruleset}") 123 | assert_rules_enabled(result, %w{MD001 MY001}) 124 | assert_ran_ok(result) 125 | end 126 | 127 | def test_rule_inclusion_cli 128 | result = run_cli('-r MD001 -l') 129 | assert_rules_enabled(result, ['MD001'], true) 130 | assert_ran_ok(result) 131 | end 132 | 133 | def test_rule_exclusion_cli 134 | result = run_cli('-r ~MD001 -l') 135 | assert_rules_disabled(result, ['MD001']) 136 | assert_ran_ok(result) 137 | end 138 | 139 | def test_rule_inclusion_with_exclusion_cli 140 | result = run_cli('-r ~MD001,MD039 -l') 141 | assert_rules_enabled(result, ['MD039'], true) 142 | assert_ran_ok(result) 143 | end 144 | 145 | def test_tag_inclusion_cli 146 | result = run_cli('-t headers -l') 147 | assert_rules_enabled(result, %w{MD001 MD002 MD003}) 148 | assert_rules_disabled(result, %w{MD004 MD005 MD006}) 149 | assert_ran_ok(result) 150 | end 151 | 152 | def test_tag_exclusion_cli 153 | result = run_cli('-t ~headers -l') 154 | assert_ran_ok(result) 155 | assert_rules_disabled(result, %w{MD001 MD002 MD003}) 156 | assert_rules_enabled(result, %w{MD004 MD005 MD006}) 157 | end 158 | 159 | def test_rule_inclusion_config 160 | result = run_cli_with_custom_rc_file('-l', 'mdlrc_enable_rules') 161 | assert_ran_ok(result) 162 | assert_rules_enabled(result, %w{MD001 MD002}, true) 163 | end 164 | 165 | def test_rule_exclusion_config 166 | result = run_cli_with_custom_rc_file('-l', 'mdlrc_disable_rules') 167 | assert_correctly_disabled(result) 168 | end 169 | 170 | def test_mdlrc_loading_from_current_dir_by_default 171 | inside_tmp_dir do |dir| 172 | with_mdlrc('mdlrc_disable_rules', dir) do 173 | result = run_cli_without_rc_flag('-l') 174 | assert_correctly_disabled(result) 175 | end 176 | end 177 | end 178 | 179 | def test_mdlrc_loading_ascends_until_it_finds_an_rc_file 180 | Dir.mktmpdir do |parent_dir| 181 | inside_tmp_dir(parent_dir) do 182 | with_mdlrc('mdlrc_disable_rules', parent_dir) do 183 | result = run_cli_without_rc_flag('-l') 184 | assert_correctly_disabled(result) 185 | end 186 | end 187 | end 188 | end 189 | 190 | def test_tag_inclusion_config 191 | result = run_cli_with_custom_rc_file('-l', 'mdlrc_enable_tags') 192 | assert_ran_ok(result) 193 | assert_rules_enabled(result, %w{MD001 MD002 MD009 MD010}) 194 | assert_rules_disabled(result, %w{MD004 MD005}) 195 | end 196 | 197 | def test_tag_exclusion_config 198 | result = run_cli_with_custom_rc_file('-l', 'mdlrc_disable_tags') 199 | assert_ran_ok(result) 200 | assert_rules_enabled(result, %w{MD004 MD030 MD032}) 201 | assert_rules_disabled(result, %w{MD001 MD005}) 202 | end 203 | 204 | def test_rule_inclusion_alias_cli 205 | result = run_cli('-l -r header-increment') 206 | assert_ran_ok(result) 207 | assert_rules_enabled(result, ['MD001'], true) 208 | end 209 | 210 | def test_rule_exclusion_alias_cli 211 | result = run_cli('-l -r ~header-increment') 212 | assert_ran_ok(result) 213 | assert_rules_disabled(result, ['MD001']) 214 | assert_rules_enabled(result, ['MD002']) 215 | end 216 | 217 | def test_directory_scanning 218 | path = File.expand_path( 219 | './fixtures/dir_with_md_and_markdown', 220 | File.dirname(__FILE__), 221 | ) 222 | result = run_cli(path.to_s) 223 | lines_output = result[:stdout].lines 224 | interested_lines = lines_output[0...lines_output.index("\n")] 225 | files_with_issues = interested_lines.map { |l| l.split(':')[0] }.sort 226 | assert_equal(files_with_issues, ["#{path}/bar.markdown", "#{path}/foo.md"]) 227 | end 228 | 229 | def test_ignore_front_matter 230 | path = File.expand_path('./fixtures/front_matter', File.dirname(__FILE__)) 231 | files = ['jekyll_post.md', 'jekyll_post_2.md'].map do |f| 232 | File.join(path, f) 233 | end.join(' ') 234 | result = run_cli("-i -r MD001,MD041,MD034 #{files}") 235 | 236 | expected = <<~OUTPUT 237 | #{path}/jekyll_post.md:16: MD001 Header levels should only increment by one level at a time 238 | #{path}/jekyll_post_2.md:16: MD001 Header levels should only increment by one level at a time 239 | 240 | Further documentation is available for these failures: 241 | - MD001: https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md001---header-levels-should-only-increment-by-one-level-at-a-time 242 | OUTPUT 243 | 244 | assert_equal(result[:stdout], expected) 245 | end 246 | 247 | def test_unprintable_chars 248 | path = File.expand_path( 249 | './fixtures/unprintable_chars', 250 | File.dirname(__FILE__), 251 | ) 252 | files = (1..3).map do |i| 253 | File.join(path, "test#{i}") 254 | end.join(' ') 255 | result = run_cli(files) 256 | 257 | assert_equal(result[:stdout], '') 258 | end 259 | 260 | def test_printing_url_links_in_tty 261 | result = run_cli_as_tty('-r MD002', "## header2\n") 262 | 263 | link_text = 'MD002' 264 | url = 'https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header' 265 | expected_link = "\e]8;;#{url}\e\\#{link_text}\e]8;;\e\\" 266 | 267 | expected = <<~OUTPUT 268 | (stdin):1: #{expected_link} First header should be a top level header 269 | OUTPUT 270 | 271 | assert_equal(result[:stdout], expected) 272 | end 273 | 274 | def test_printing_url_links_outside_tty 275 | result = run_cli_with_input('-r MD002', "## header2\n") 276 | 277 | url = 'https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md002---first-header-should-be-a-top-level-header' 278 | 279 | expected = <<~OUTPUT 280 | (stdin):1: MD002 First header should be a top level header 281 | 282 | Further documentation is available for these failures: 283 | - MD002: #{url} 284 | OUTPUT 285 | 286 | assert_equal(result[:stdout], expected) 287 | end 288 | 289 | def test_docs_declaration 290 | ruleset1 = File.expand_path('./fixtures/docs_ruleset_1.rb', __dir__) 291 | ruleset2 = File.expand_path('./fixtures/docs_ruleset_2.rb', __dir__) 292 | 293 | result = run_cli_with_input("-d -u #{ruleset1},#{ruleset2}", "!\n") 294 | 295 | expected = <<~OUTPUT 296 | (stdin):1: MY002 Documents must start with A 297 | (stdin):1: MY003 Documents must start with B 298 | (stdin):1: MY004 Documents must start with C 299 | (stdin):1: MY005 Documents must start with D 300 | (stdin):1: MY006 Documents must start with E 301 | (stdin):1: MY007 Documents must start with F 302 | 303 | Further documentation is available for these failures: 304 | - MY002: https://example.com/static-docs 305 | - MY003: https://example.com/override-docs 306 | - MY004: https://example.com/MY004#364fe83d86ba1cdcc2ea87aec2fc5ec0 307 | - MY005: https://example.com/override-docs 308 | - MY006: https://example.com/later-declaration 309 | - MY007: https://example.com/dynamic-override/MY007#documents-must-start-with-f 310 | OUTPUT 311 | 312 | assert_equal(expected, result[:stdout]) 313 | end 314 | 315 | def test_graceful_not_found 316 | file_path = 'a/file/that/does/not/exist.md' 317 | dir_path = 'a/folder/that/does/not/exist' 318 | 319 | file_result = run_cli(file_path) 320 | dir_result = run_cli(dir_path) 321 | 322 | file_expected = "Errno::ENOENT: No such file or directory - #{file_path}\n" 323 | dir_expected = "Errno::ENOENT: No such file or directory - #{dir_path}\n" 324 | 325 | # No stdout 326 | assert_equal('', file_result[:stdout]) 327 | assert_equal('', dir_result[:stdout]) 328 | 329 | # Statuses are 3 330 | assert_equal(3, file_result[:status]) 331 | assert_equal(3, dir_result[:status]) 332 | 333 | # Check error message is expected 334 | assert_equal(file_expected, file_result[:stderr]) 335 | assert_equal(dir_expected, dir_result[:stderr]) 336 | end 337 | 338 | private 339 | 340 | def run_cli_with_input(args, stdin) 341 | run_cmd("#{mdl_script} -c #{default_rc_file} #{args}", stdin) 342 | end 343 | 344 | def run_cli_as_tty(args, stdin) 345 | fake_tty = File.expand_path('./fixtures/fake_tty.rb', __dir__) 346 | run_cmd("#{mdl_script} -c #{default_rc_file} -u #{fake_tty} #{args}", stdin) 347 | end 348 | 349 | def run_cli_without_rc_flag(args) 350 | run_cmd("#{mdl_script} #{args}", '') 351 | end 352 | 353 | def run_cli(args) 354 | run_cmd("#{mdl_script} -c #{default_rc_file} #{args}", '') 355 | end 356 | 357 | def run_cli_with_custom_rc_file(args, filename) 358 | run_cmd("#{mdl_script} -c #{fixture_rc(filename)} #{args}", '') 359 | end 360 | 361 | def run_cli_with_file_and_ascii_env(content) 362 | Tempfile.create('foo') do |f| 363 | f.write(content) 364 | f.close 365 | 366 | run_cmd("ruby -E ASCII #{mdl_script} -c #{default_rc_file} #{f.path}", '') 367 | end 368 | end 369 | 370 | def run_cmd(command, stdin) 371 | result = {} 372 | result[:stdout], result[:stderr], result[:status] = \ 373 | Open3.capture3('bundle', 'exec', *command.split, :stdin_data => stdin) 374 | result[:status] = result[:status].exitstatus 375 | result 376 | end 377 | 378 | def mdl_script 379 | File.expand_path('../bin/mdl', __dir__) 380 | end 381 | 382 | def fixture_rc(filename) 383 | File.expand_path("../fixtures/#{filename}", __FILE__) 384 | end 385 | 386 | def default_rc_file 387 | fixture_rc('default_mdlrc') 388 | end 389 | 390 | def inside_tmp_dir(base_dir = Dir.tmpdir) 391 | Dir.mktmpdir(nil, base_dir) do |dir| 392 | Dir.chdir(dir) { yield(dir) } 393 | end 394 | end 395 | 396 | def assert_rules_enabled(result, rules, only_these_rules = false) 397 | # Asserts that the given rules are enabled given the output of mdl -l 398 | # If only_these_rules is set, then it asserts that the given rules and no 399 | # others are enabled. 400 | lines = result[:stdout].split("\n") 401 | assert_equal('Enabled rules:', lines.first) 402 | lines.shift 403 | rules = rules.to_set 404 | enabled_rules = lines.map { |l| l.split.first }.to_set 405 | if only_these_rules 406 | assert_equal(rules, enabled_rules) 407 | else 408 | assert_equal(Set.new, rules - enabled_rules) 409 | end 410 | end 411 | 412 | def assert_rules_disabled(result, rules) 413 | # Asserts that the given rules are _not_ enabled given the output of mdl -l 414 | lines = result[:stdout].split("\n") 415 | assert_equal('Enabled rules:', lines.first) 416 | lines.shift 417 | rules = rules.to_set 418 | enabled_rules = lines.map { |l| l.split.first }.to_set 419 | assert_equal(Set.new, rules & enabled_rules) 420 | end 421 | 422 | def assert_ran_ok(result) 423 | assert_equal(0, result[:status]) 424 | assert_equal('', result[:stderr]) 425 | end 426 | 427 | def assert_correctly_disabled(result) 428 | assert_ran_ok(result) 429 | assert_rules_disabled(result, %w{MD001 MD002}) 430 | assert_rules_enabled(result, %w{MD003 MD004}) 431 | end 432 | 433 | def with_mdlrc(filename, dest_dir = Dir.pwd) 434 | rc_path = File.join(dest_dir, '.mdlrc') 435 | FileUtils.cp(fixture_rc(filename), rc_path) 436 | yield 437 | ensure 438 | File.delete(rc_path) 439 | end 440 | end 441 | -------------------------------------------------------------------------------- /lib/mdl/rules.rb: -------------------------------------------------------------------------------- 1 | docs do |id, description| 2 | url_hash = [id.downcase, 3 | description.downcase.gsub(/[^a-z]+/, '-')].join('---') 4 | "https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md##{url_hash}" 5 | end 6 | 7 | rule 'MD001', 'Header levels should only increment by one level at a time' do 8 | tags :headers 9 | aliases 'header-increment' 10 | check do |doc| 11 | headers = doc.find_type(:header) 12 | old_level = nil 13 | errors = [] 14 | headers.each do |h| 15 | errors << h[:location] if old_level && (h[:level] > old_level + 1) 16 | old_level = h[:level] 17 | end 18 | errors 19 | end 20 | end 21 | 22 | rule 'MD002', 'First header should be a top level header' do 23 | tags :headers 24 | aliases 'first-header-h1' 25 | params :level => 1 26 | check do |doc| 27 | first_header = doc.find_type(:header).first 28 | if first_header && (first_header[:level] != @params[:level]) 29 | [first_header[:location]] 30 | end 31 | end 32 | end 33 | 34 | rule 'MD003', 'Header style' do 35 | # Header styles are things like ### and adding underscores 36 | # See https://daringfireball.net/projects/markdown/syntax#header 37 | tags :headers 38 | aliases 'header-style' 39 | # :style can be one of :consistent, :atx, :atx_closed, :setext 40 | params :style => :consistent 41 | check do |doc| 42 | headers = doc.find_type_elements(:header, false) 43 | if headers.empty? 44 | nil 45 | else 46 | doc_style = if @params[:style] == :consistent 47 | doc.header_style(headers.first) 48 | else 49 | @params[:style] 50 | end 51 | if doc_style == :setext_with_atx 52 | headers.map do |h| 53 | doc.element_linenumber(h) \ 54 | unless (doc.header_style(h) == :setext) || \ 55 | ((doc.header_style(h) == :atx) && \ 56 | (h.options[:level] > 2)) 57 | end.compact 58 | else 59 | headers.map do |h| 60 | doc.element_linenumber(h) \ 61 | if doc.header_style(h) != doc_style 62 | end.compact 63 | end 64 | end 65 | end 66 | end 67 | 68 | rule 'MD004', 'Unordered list style' do 69 | tags :bullet, :ul 70 | aliases 'ul-style' 71 | # :style can be one of :consistent, :asterisk, :plus, :dash, :sublist 72 | params :style => :consistent 73 | check do |doc| 74 | bullets = doc.find_type_elements(:ul).map do |l| 75 | doc.find_type_elements(:li, false, l.children) 76 | end.flatten 77 | if bullets.empty? 78 | nil 79 | else 80 | doc_style = case @params[:style] 81 | when :consistent 82 | doc.list_style(bullets.first) 83 | when :sublist 84 | {} 85 | else 86 | @params[:style] 87 | end 88 | results = [] 89 | bullets.each do |b| 90 | if @params[:style] == :sublist 91 | level = b.options[:element_level] 92 | if doc_style[level] 93 | if doc_style[level] != doc.list_style(b) 94 | results << doc.element_linenumber(b) 95 | end 96 | else 97 | doc_style[level] = doc.list_style(b) 98 | end 99 | elsif doc.list_style(b) != doc_style 100 | results << doc.element_linenumber(b) 101 | end 102 | end 103 | results.compact 104 | end 105 | end 106 | end 107 | 108 | rule 'MD005', 'Inconsistent indentation for list items at the same level' do 109 | tags :bullet, :ul, :indentation 110 | aliases 'list-indent' 111 | check do |doc| 112 | bullets = doc.find_type(:li) 113 | errors = [] 114 | indent_levels = [] 115 | bullets.each do |b| 116 | indent_level = doc.indent_for(doc.element_line(b)) 117 | if indent_levels[b[:element_level]].nil? 118 | indent_levels[b[:element_level]] = indent_level 119 | end 120 | if indent_level != indent_levels[b[:element_level]] 121 | errors << doc.element_linenumber(b) 122 | end 123 | end 124 | errors 125 | end 126 | end 127 | 128 | rule 'MD006', 'Consider starting bulleted lists at the beginning of the line' do 129 | # Starting at the beginning of the line means that indentation for each 130 | # bullet level can be identical. 131 | tags :bullet, :ul, :indentation 132 | aliases 'ul-start-left' 133 | check do |doc| 134 | doc.find_type(:ul, false).reject do |e| 135 | doc.indent_for(doc.element_line(e)) == 0 136 | end.map { |e| e[:location] } 137 | end 138 | end 139 | 140 | rule 'MD007', 'Unordered list indentation' do 141 | tags :bullet, :ul, :indentation 142 | aliases 'ul-indent' 143 | # Do not default to < 3, see PR#373 or the comments in RULES.md 144 | params :indent => 3 145 | check do |doc| 146 | errors = [] 147 | indents = doc.find_type(:ul).map do |e| 148 | [doc.indent_for(doc.element_line(e)), doc.element_linenumber(e)] 149 | end 150 | curr_indent = indents[0][0] unless indents.empty? 151 | indents.each do |indent, linenum| 152 | if (indent > curr_indent) && (indent - curr_indent != @params[:indent]) 153 | errors << linenum 154 | end 155 | curr_indent = indent 156 | end 157 | errors 158 | end 159 | end 160 | 161 | rule 'MD009', 'Trailing spaces' do 162 | tags :whitespace 163 | aliases 'no-trailing-spaces' 164 | params :br_spaces => 2 165 | check do |doc| 166 | errors = doc.matching_lines(/\s$/) 167 | if params[:br_spaces] > 1 168 | errors -= doc.matching_lines(/\S\s{#{params[:br_spaces]}}$/) 169 | end 170 | errors 171 | end 172 | end 173 | 174 | rule 'MD010', 'Hard tabs' do 175 | tags :whitespace, :hard_tab 176 | aliases 'no-hard-tabs' 177 | params :ignore_code_blocks => false 178 | check do |doc| 179 | # Every line in the document that is part of a code block. Blank lines 180 | # inside of a code block are acceptable. 181 | codeblock_lines = doc.find_type_elements(:codeblock).map do |e| 182 | (doc.element_linenumber(e).. 183 | doc.element_linenumber(e) + e.value.lines.count).to_a 184 | end.flatten 185 | 186 | # Check for lines with hard tab 187 | hard_tab_lines = doc.matching_lines(/\t/) 188 | # Remove lines with hard tabs, if they stem from codeblock 189 | hard_tab_lines -= codeblock_lines if params[:ignore_code_blocks] 190 | hard_tab_lines 191 | end 192 | end 193 | 194 | rule 'MD011', 'Reversed link syntax' do 195 | tags :links 196 | aliases 'no-reversed-links' 197 | check do |doc| 198 | doc.matching_text_element_lines(/\([^)]+\)\[[^\]]+\]/) 199 | end 200 | end 201 | 202 | rule 'MD012', 'Multiple consecutive blank lines' do 203 | tags :whitespace, :blank_lines 204 | aliases 'no-multiple-blanks' 205 | check do |doc| 206 | # Every line in the document that is part of a code block. Blank lines 207 | # inside of a code block are acceptable. 208 | codeblock_lines = doc.find_type_elements(:codeblock).map do |e| 209 | (doc.element_linenumber(e).. 210 | doc.element_linenumber(e) + e.value.lines.count).to_a 211 | end.flatten 212 | blank_lines = doc.matching_lines(/^\s*$/) 213 | cons_blank_lines = blank_lines.each_cons(2).select do |p, n| 214 | n - p == 1 215 | end.map { |_p, n| n } 216 | cons_blank_lines - codeblock_lines 217 | end 218 | end 219 | 220 | rule 'MD013', 'Line length' do 221 | tags :line_length 222 | aliases 'line-length' 223 | params :line_length => 80, :ignore_code_blocks => false, :code_blocks => true, 224 | :tables => true 225 | 226 | check do |doc| 227 | # Every line in the document that is part of a code block. 228 | codeblock_lines = doc.find_type_elements(:codeblock).map do |e| 229 | (doc.element_linenumber(e).. 230 | doc.element_linenumber(e) + e.value.lines.count).to_a 231 | end.flatten 232 | # Every line in the document that is part of a table. 233 | locations = doc.elements 234 | .map { |e| [e.options[:location], e] } 235 | .reject { |l, _| l.nil? } 236 | table_lines = locations.map.with_index do |(l, e), i| 237 | if e.type == :table 238 | if i + 1 < locations.size 239 | (l..locations[i + 1].first - 1).to_a 240 | else 241 | (l..doc.lines.count).to_a 242 | end 243 | end 244 | end.flatten 245 | overlines = doc.matching_lines(/^.{#{@params[:line_length]}}.*\s/) 246 | if !params[:code_blocks] || params[:ignore_code_blocks] 247 | overlines -= codeblock_lines 248 | unless params[:code_blocks] 249 | warn 'MD013 warning: Parameter :code_blocks is deprecated.' 250 | warn ' Please replace \":code_blocks => false\" by '\ 251 | '\":ignore_code_blocks => true\" in your configuration.' 252 | end 253 | end 254 | overlines -= table_lines unless params[:tables] 255 | overlines 256 | end 257 | end 258 | 259 | rule 'MD014', 'Dollar signs used before commands without showing output' do 260 | tags :code 261 | aliases 'commands-show-output' 262 | check do |doc| 263 | doc.find_type_elements(:codeblock).select do |e| 264 | !e.value.empty? && 265 | !e.value.split(/\n+/).map { |l| l.match(/^\$\s/) }.include?(nil) 266 | end.map { |e| doc.element_linenumber(e) } 267 | end 268 | end 269 | 270 | rule 'MD018', 'No space after hash on atx style header' do 271 | tags :headers, :atx, :spaces 272 | aliases 'no-missing-space-atx' 273 | check do |doc| 274 | doc.find_type_elements(:header).select do |h| 275 | doc.header_style(h) == :atx && doc.element_line(h).match(/^#+[^#\s]/) 276 | end.map { |h| doc.element_linenumber(h) } 277 | end 278 | end 279 | 280 | rule 'MD019', 'Multiple spaces after hash on atx style header' do 281 | tags :headers, :atx, :spaces 282 | aliases 'no-multiple-space-atx' 283 | check do |doc| 284 | doc.find_type_elements(:header).select do |h| 285 | doc.header_style(h) == :atx && doc.element_line(h).match(/^#+\s\s/) 286 | end.map { |h| doc.element_linenumber(h) } 287 | end 288 | end 289 | 290 | rule 'MD020', 'No space inside hashes on closed atx style header' do 291 | tags :headers, :atx_closed, :spaces 292 | aliases 'no-missing-space-closed-atx' 293 | check do |doc| 294 | doc.find_type_elements(:header).select do |h| 295 | doc.header_style(h) == :atx_closed \ 296 | && (doc.element_line(h).match(/^#+[^#\s]/) \ 297 | || doc.element_line(h).match(/[^#\s\\]#+$/)) 298 | end.map { |h| doc.element_linenumber(h) } 299 | end 300 | end 301 | 302 | rule 'MD021', 'Multiple spaces inside hashes on closed atx style header' do 303 | tags :headers, :atx_closed, :spaces 304 | aliases 'no-multiple-space-closed-atx' 305 | check do |doc| 306 | doc.find_type_elements(:header).select do |h| 307 | doc.header_style(h) == :atx_closed \ 308 | && (doc.element_line(h).match(/^#+\s\s/) \ 309 | || doc.element_line(h).match(/\s\s#+$/)) 310 | end.map { |h| doc.element_linenumber(h) } 311 | end 312 | end 313 | 314 | rule 'MD022', 'Headers should be surrounded by blank lines' do 315 | tags :headers, :blank_lines 316 | aliases 'blanks-around-headers' 317 | check do |doc| 318 | errors = [] 319 | doc.find_type_elements(:header, false).each do |h| 320 | header_bad = false 321 | linenum = doc.element_linenumber(h) 322 | # Check previous line 323 | header_bad = true if (linenum > 1) && !doc.lines[linenum - 2].empty? 324 | # Check next line 325 | next_line_idx = doc.header_style(h) == :setext ? linenum + 1 : linenum 326 | next_line = doc.lines[next_line_idx] 327 | header_bad = true if !next_line.nil? && !next_line.empty? 328 | errors << linenum if header_bad 329 | end 330 | # Kramdown requires that headers start on a block boundary, so in most 331 | # cases it won't pick up a header without a blank line before it. We need 332 | # to check regular text and pick out headers ourselves too 333 | doc.find_type_elements(:p, false).each do |p| 334 | linenum = doc.element_linenumber(p) 335 | text = p.children.select { |e| e.type == :text }.map(&:value).join 336 | lines = text.split("\n") 337 | prev_lines = ['', ''] 338 | lines.each do |line| 339 | # First look for ATX style headers without blank lines before 340 | errors << linenum if line.match(/^\#{1,6}/) && !prev_lines[1].empty? 341 | # Next, look for setext style 342 | if line.match(/^(-+|=+)\s*$/) && !prev_lines[0].empty? 343 | errors << (linenum - 1) 344 | end 345 | linenum += 1 346 | prev_lines << line 347 | prev_lines.shift 348 | end 349 | end 350 | errors.sort 351 | end 352 | end 353 | 354 | rule 'MD023', 'Headers must start at the beginning of the line' do 355 | tags :headers, :spaces 356 | aliases 'header-start-left' 357 | check do |doc| 358 | errors = [] 359 | # The only type of header with spaces actually parsed as such is setext 360 | # style where only the text is indented. We check for that first. 361 | doc.find_type_elements(:header, false).each do |h| 362 | errors << doc.element_linenumber(h) if doc.element_line(h).match(/^\s/) 363 | end 364 | # Next we have to look for things that aren't parsed as headers because 365 | # they start with spaces. 366 | doc.find_type_elements(:p, false).each do |p| 367 | linenum = doc.element_linenumber(p) 368 | lines = doc.extract_text(p) 369 | prev_line = '' 370 | lines.each do |line| 371 | # First look for ATX style headers 372 | errors << linenum if line.match(/^\s+\#{1,6}/) 373 | # Next, look for setext style 374 | if line.match(/^\s+(-+|=+)\s*$/) && !prev_line.empty? 375 | errors << (linenum - 1) 376 | end 377 | linenum += 1 378 | prev_line = line 379 | end 380 | end 381 | errors.sort 382 | end 383 | end 384 | 385 | rule 'MD024', 'Multiple headers with the same content' do 386 | tags :headers 387 | aliases 'no-duplicate-header' 388 | params :allow_different_nesting => false 389 | check do |doc| 390 | headers = doc.find_type(:header) 391 | allow_different_nesting = params[:allow_different_nesting] 392 | 393 | duplicates = headers.select do |h| 394 | headers.any? do |e| 395 | e[:location] < h[:location] && 396 | e[:raw_text] == h[:raw_text] && 397 | (allow_different_nesting == false || e[:level] != h[:level]) 398 | end 399 | end.to_set 400 | 401 | if allow_different_nesting 402 | same_nesting_duplicates = Set.new 403 | stack = [] 404 | current_level = 0 405 | doc.find_type(:header).each do |header| 406 | level = header[:level] 407 | text = header[:raw_text] 408 | 409 | if current_level > level 410 | stack.pop 411 | elsif current_level < level 412 | stack.push([text]) 413 | elsif stack.last.include?(text) 414 | same_nesting_duplicates.add(header) 415 | end 416 | 417 | current_level = level 418 | end 419 | 420 | duplicates += same_nesting_duplicates 421 | end 422 | 423 | duplicates.map { |h| doc.element_linenumber(h) } 424 | end 425 | end 426 | 427 | rule 'MD025', 'Multiple top level headers in the same document' do 428 | tags :headers 429 | aliases 'single-h1' 430 | params :level => 1 431 | check do |doc| 432 | headers = doc.find_type(:header, false).select do |h| 433 | h[:level] == params[:level] 434 | end 435 | if !headers.empty? && (doc.element_linenumber(headers[0]) == 1) 436 | headers[1..].map { |h| doc.element_linenumber(h) } 437 | end 438 | end 439 | end 440 | 441 | rule 'MD026', 'Trailing punctuation in header' do 442 | tags :headers 443 | aliases 'no-trailing-punctuation' 444 | params :punctuation => '.,;:!?' 445 | check do |doc| 446 | doc.find_type(:header).select do |h| 447 | h[:raw_text].match(/[#{params[:punctuation]}]$/) 448 | end.map do |h| 449 | doc.element_linenumber(h) 450 | end 451 | end 452 | end 453 | 454 | rule 'MD027', 'Multiple spaces after blockquote symbol' do 455 | tags :blockquote, :whitespace, :indentation 456 | aliases 'no-multiple-space-blockquote' 457 | check do |doc| 458 | errors = [] 459 | doc.find_type_elements(:blockquote).each do |e| 460 | linenum = doc.element_linenumber(e) 461 | lines = doc.extract_as_text(e) 462 | # Handle first line specially as whitespace is stripped from the text 463 | # element 464 | errors << linenum if doc.element_line(e).match(/^\s*> /) 465 | lines.each do |line| 466 | errors << linenum if line.start_with?(' ') 467 | linenum += 1 468 | end 469 | end 470 | errors 471 | end 472 | end 473 | 474 | rule 'MD028', 'Blank line inside blockquote' do 475 | tags :blockquote, :whitespace 476 | aliases 'no-blanks-blockquote' 477 | check do |doc| 478 | def check_blockquote(errors, elements) 479 | prev = [nil, nil, nil] 480 | elements.each do |e| 481 | prev.shift 482 | prev << e.type 483 | if prev == %i{blockquote blank blockquote} 484 | # The current location is the start of the second blockquote, so the 485 | # line before will be a blank line in between the two, or at least the 486 | # lowest blank line if there are more than one. 487 | errors << (e.options[:location] - 1) 488 | end 489 | check_blockquote(errors, e.children) 490 | end 491 | end 492 | errors = [] 493 | check_blockquote(errors, doc.elements) 494 | errors 495 | end 496 | end 497 | 498 | rule 'MD029', 'Ordered list item prefix' do 499 | tags :ol 500 | aliases 'ol-prefix' 501 | # Style can be :one or :ordered 502 | params :style => :one 503 | check do |doc| 504 | case params[:style] 505 | when :ordered 506 | doc.find_type_elements(:ol).map do |l| 507 | doc.find_type_elements(:li, false, l.children) 508 | .map.with_index do |i, idx| 509 | unless doc.element_line(i).strip.start_with?("#{idx + 1}. ") 510 | doc.element_linenumber(i) 511 | end 512 | end 513 | end.flatten.compact 514 | when :one 515 | doc.find_type_elements(:ol).map do |l| 516 | doc.find_type_elements(:li, false, l.children) 517 | end.flatten.map do |i| 518 | unless doc.element_line(i).strip.start_with?('1. ') 519 | doc.element_linenumber(i) 520 | end 521 | end.compact 522 | end 523 | end 524 | end 525 | 526 | rule 'MD030', 'Spaces after list markers' do 527 | tags :ol, :ul, :whitespace 528 | aliases 'list-marker-space' 529 | params :ul_single => 1, :ol_single => 1, :ul_multi => 1, :ol_multi => 1 530 | check do |doc| 531 | errors = [] 532 | doc.find_type_elements(%i{ul ol}).each do |l| 533 | list_type = l.type.to_s 534 | items = doc.find_type_elements(:li, false, l.children) 535 | # The entire list is to use the multi-paragraph spacing rule if any of 536 | # the items in it have multiple paragraphs/other block items. 537 | srule = items.map { |i| i.children.length }.max > 1 ? 'multi' : 'single' 538 | items.each do |i| 539 | line = doc.element_line(i) 540 | # See #278 - sometimes we think non-printable characters are list 541 | # items even if they are not, so this ignore those and prevents 542 | # us from crashing 543 | next if line.empty? 544 | 545 | actual_spaces = line.gsub(/^> /, '').match(/^\s*\S+(\s+)/)[1].length 546 | required_spaces = params["#{list_type}_#{srule}".to_sym] 547 | errors << doc.element_linenumber(i) if required_spaces != actual_spaces 548 | end 549 | end 550 | errors 551 | end 552 | end 553 | 554 | rule 'MD031', 'Fenced code blocks should be surrounded by blank lines' do 555 | tags :code, :blank_lines 556 | aliases 'blanks-around-fences' 557 | check do |doc| 558 | errors = [] 559 | # Some parsers (including kramdown) have trouble detecting fenced code 560 | # blocks without surrounding whitespace, so examine the lines directly. 561 | in_code = false 562 | fence = nil 563 | lines = [''] + doc.lines + [''] 564 | lines.each_with_index do |line, linenum| 565 | line.strip.match(/^(`{3,}|~{3,})/) 566 | unless Regexp.last_match(1) && 567 | ( 568 | !in_code || 569 | (Regexp.last_match(1).slice(0, fence.length) == fence) 570 | ) 571 | next 572 | end 573 | 574 | fence = in_code ? nil : Regexp.last_match(1) 575 | in_code = !in_code 576 | if (in_code && !lines[linenum - 1].empty?) || 577 | (!in_code && !lines[linenum + 1].empty?) 578 | errors << linenum 579 | end 580 | end 581 | errors 582 | end 583 | end 584 | 585 | rule 'MD032', 'Lists should be surrounded by blank lines' do 586 | tags :bullet, :ul, :ol, :blank_lines 587 | aliases 'blanks-around-lists' 588 | check do |doc| 589 | errors = [] 590 | # Some parsers (including kramdown) have trouble detecting lists 591 | # without surrounding whitespace, so examine the lines directly. 592 | in_list = false 593 | in_code = false 594 | fence = nil 595 | prev_line = '' 596 | doc.lines.each_with_index do |line, linenum| 597 | next if line.strip == '{:toc}' 598 | 599 | unless in_code 600 | list_marker = line.strip.match(/^([*+\-]|(\d+\.))\s/) 601 | if list_marker && !in_list && !prev_line.match(/^($|\s)/) 602 | errors << (linenum + 1) 603 | elsif !list_marker && in_list && !line.match(/^($|\s)/) 604 | errors << linenum 605 | end 606 | in_list = list_marker 607 | end 608 | line.strip.match(/^(`{3,}|~{3,})/) 609 | if Regexp.last_match(1) && ( 610 | !in_code || (Regexp.last_match(1).slice(0, fence.length) == fence) 611 | ) 612 | fence = in_code ? nil : Regexp.last_match(1) 613 | in_code = !in_code 614 | in_list = false 615 | end 616 | prev_line = line 617 | end 618 | errors.uniq 619 | end 620 | end 621 | 622 | rule 'MD033', 'Inline HTML' do 623 | tags :html 624 | aliases 'no-inline-html' 625 | params :allowed_elements => '' 626 | check do |doc| 627 | doc.element_linenumbers(doc.find_type(:html_element)) 628 | allowed = params[:allowed_elements].delete(" \t\r\n").downcase.split(',') 629 | errors = doc.find_type_elements(:html_element).reject do |e| 630 | allowed.include?(e.value) 631 | end 632 | doc.element_linenumbers(errors) 633 | end 634 | end 635 | 636 | rule 'MD034', 'Bare URL used' do 637 | tags :links, :url 638 | aliases 'no-bare-urls' 639 | check do |doc| 640 | doc.matching_text_element_lines(%r{https?://}) 641 | end 642 | end 643 | 644 | rule 'MD035', 'Horizontal rule style' do 645 | tags :hr 646 | aliases 'hr-style' 647 | params :style => :consistent 648 | check do |doc| 649 | hrs = doc.find_type(:hr) 650 | if hrs.empty? 651 | [] 652 | else 653 | doc_style = if params[:style] == :consistent 654 | doc.element_line(hrs[0]) 655 | else 656 | params[:style] 657 | end 658 | doc.element_linenumbers( 659 | hrs.reject { |e| doc.element_line(e) == doc_style }, 660 | ) 661 | end 662 | end 663 | end 664 | 665 | rule 'MD036', 'Emphasis used instead of a header' do 666 | tags :headers, :emphasis 667 | aliases 'no-emphasis-as-header' 668 | params :punctuation => '.,;:!?' 669 | check do |doc| 670 | # We are looking for a paragraph consisting entirely of emphasized 671 | # (italic/bold) text. 672 | errors = [] 673 | doc.find_type_elements(:p, false).each do |p| 674 | next if p.children.length > 1 675 | next unless %i{em strong}.include?(p.children[0].type) 676 | 677 | lines = doc.extract_text(p.children[0], '', false) 678 | next if lines.length > 1 679 | next if lines.empty? 680 | next if lines[0].match(/[#{params[:punctuation]}]$/) 681 | 682 | errors << doc.element_linenumber(p) 683 | end 684 | errors 685 | end 686 | end 687 | 688 | rule 'MD037', 'Spaces inside emphasis markers' do 689 | tags :whitespace, :emphasis 690 | aliases 'no-space-in-emphasis' 691 | check do |doc| 692 | # Kramdown doesn't parse emphasis with spaces, which means we can just 693 | # look for emphasis patterns inside regular text with spaces just inside 694 | # them. 695 | (doc.matching_text_element_lines(/\s(\*\*?|__?)\s.+\1/) | \ 696 | doc.matching_text_element_lines(/(\*\*?|__?).+\s\1\s/)).sort 697 | end 698 | end 699 | 700 | rule 'MD038', 'Spaces inside code span elements' do 701 | tags :whitespace, :code 702 | aliases 'no-space-in-code' 703 | check do |doc| 704 | # We only want to check single line codespan elements and not fenced code 705 | # block that happen to be parsed as code spans. 706 | doc.element_linenumbers( 707 | doc.find_type_elements(:codespan).select do |i| 708 | i.value.match(/(^\s|\s$)/) && !i.value.include?("\n") 709 | end, 710 | ) 711 | end 712 | end 713 | 714 | rule 'MD039', 'Spaces inside link text' do 715 | tags :whitespace, :links 716 | aliases 'no-space-in-links' 717 | check do |doc| 718 | doc.element_linenumbers( 719 | doc.find_type_elements(:a).reject { |e| e.children.empty? }.select do |e| 720 | e.children.first.type == :text && e.children.last.type == :text && ( 721 | e.children.first.value.start_with?(' ') || 722 | e.children.last.value.end_with?(' ')) 723 | end, 724 | ) 725 | end 726 | end 727 | 728 | rule 'MD040', 'Fenced code blocks should have a language specified' do 729 | tags :code, :language 730 | aliases 'fenced-code-language' 731 | check do |doc| 732 | # Kramdown parses code blocks with language settings as code blocks with 733 | # the class attribute set to language-languagename. 734 | doc.element_linenumbers(doc.find_type_elements(:codeblock).select do |i| 735 | !i.attr['class'].to_s.start_with?('language-') && 736 | !doc.element_line(i).start_with?(' ') 737 | end) 738 | end 739 | end 740 | 741 | rule 'MD041', 'First line in file should be a top level header' do 742 | tags :headers 743 | aliases 'first-line-h1' 744 | params :level => 1 745 | check do |doc| 746 | first_header = doc.find_type(:header).first 747 | [1] if first_header.nil? || (first_header[:location] != 1) \ 748 | || (first_header[:level] != params[:level]) 749 | end 750 | end 751 | 752 | rule 'MD046', 'Code block style' do 753 | tags :code 754 | aliases 'code-block-style' 755 | params :style => :fenced 756 | check do |doc| 757 | style = @params[:style] 758 | doc.element_linenumbers( 759 | doc.find_type_elements(:codeblock).select do |i| 760 | # for consistent we determine the first one 761 | if style == :consistent 762 | style = if doc.element_line(i).start_with?(' ') 763 | :indented 764 | else 765 | :fenced 766 | end 767 | end 768 | if style == :fenced 769 | # if our parent is a list or a codeblock, we need to ignore 770 | # its spaces, plus 4 more 771 | parent = i.options[:parent] 772 | ignored_spaces = 0 773 | if parent 774 | parent.options.delete(:children) 775 | parent.options.delete(:parent) 776 | if %i{li codeblock}.include?(parent.type) 777 | linenum = doc.element_linenumbers([parent]).first 778 | indent = doc.indent_for(doc.lines[linenum - 1]) 779 | ignored_spaces = indent + 4 780 | end 781 | end 782 | start = ' ' * ignored_spaces 783 | doc.element_line(i).start_with?("#{start} ") 784 | else 785 | !doc.element_line(i).start_with?(' ') 786 | end 787 | end, 788 | ) 789 | end 790 | end 791 | 792 | rule 'MD047', 'File should end with a single newline character' do 793 | tags :blank_lines 794 | aliases 'single-trailing-newline' 795 | check do |doc| 796 | error_lines = [] 797 | last_line = doc.lines[-1] 798 | error_lines.push(doc.lines.length) unless last_line.nil? || last_line.empty? 799 | error_lines 800 | end 801 | end 802 | --------------------------------------------------------------------------------