marcdouane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1ac5051e1dda30d1dd98035ba682431ac8e32788b4187772eea00e1306f5ca1d
4
+ data.tar.gz: 2b60396b0e783bf920602fd49c2d04e4288e2c9b35e1485f85e7a94a5653d046
5
+ SHA512:
6
+ metadata.gz: e054ef7079b1bf87c8203d3db165a43c3b26ffa7fc7fd1301a346060b0b25e60b7868f82550972992291b16875a5ce22512784417f489f990a673aa49fb1f501
7
+ data.tar.gz: 3982749d76b7e586a67b6ed6a5b8ef13bcc3ecea6c0877166ac6fe00605979fd2904c5c069d9b1a0d16da9bbac9e98f186c2a21264038f7849648d78166d426e
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Stéphane Maniaci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Marc d'Ouane
2
+
3
+ a beautiful, epic Markdown linter
4
+
5
+ ![Logo](logo.jpeg)
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+
11
+ ```bash
12
+ bundle add marcdouane
13
+ ```
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ ```bash
18
+ gem install marcdouane
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Checking a file with default rules:
24
+
25
+ ```sh
26
+ marcdouane check some-file.md
27
+ ```
28
+
29
+ For the complete tour just use
30
+
31
+ ```sh
32
+ marcdouane
33
+ marcdouane check --help
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ Some rules have settings (see `lib/marcdouane/rules.rb` for
39
+ now). These settings can be overriden with a YAML config file that
40
+ resembles the Rubocop config:
41
+
42
+ ```yaml
43
+ LineLength:
44
+ maximum_line_length: 90 # which is utter madness
45
+ ```
46
+
47
+ ## Development
48
+
49
+ I'm doing it
50
+
51
+ ## Contributing
52
+
53
+ A rule must inherit `Marcdouane::Rule`. It must answer to `check!` and
54
+ raise a `Marcdouane::Error` with an `ERROR_MESSAGE` and a line
55
+ number. Example:
56
+
57
+ ```ruby
58
+ class CheckAnglois < Marcdouane::Rule
59
+ ERROR_MESSAGE = "Do not mention the English"
60
+
61
+ def check!
62
+ File
63
+ .read(file)
64
+ .lines
65
+ .each_with_index(line, index) do
66
+ raise Marcdouane:Error.new(ERROR_MESSAGE, index + 1) if line =~ /anglois/
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ It is then expected to be tested throughout the Cucumber feature tests
73
+ like such:
74
+
75
+ ```feature
76
+ Feature: Built-in Markdown Rules
77
+ # [...]
78
+
79
+ Rule: Don't mention the English
80
+ Example: It fails when the English are mentioned
81
+ Given a file named "foo.md" with:
82
+ """
83
+ # Tout va bien
84
+ Pas d'anglois à l'horizon
85
+ """
86
+ When I run `marcdouane check "foo.md"`
87
+ Then it should fail with:
88
+ """
89
+ foo.md:2: Don't mention the English
90
+ """
91
+
92
+ Example: It passes when the English are not mentioned
93
+ Given a file named "foo.md" with:
94
+ """
95
+ # Tout va bien
96
+ Quelques voix dans la tête mais tranquille
97
+ """
98
+ When I run `marcdouane check "foo.md"`
99
+ Then it should pass
100
+
101
+ ```
102
+
103
+ Bug reports and pull requests are welcome on GitHub at
104
+ https://github.com/freesteph/marcdouane.
105
+
106
+ ## License
107
+
108
+ The gem is available as open source under the terms of the [MIT
109
+ License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/marcdouane ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "dry/cli"
6
+ require "marcdouane"
7
+
8
+ module Marcdouane
9
+ module CLI
10
+ module Commands
11
+ extend Dry::CLI::Registry
12
+
13
+ class Version < Dry::CLI::Command
14
+ desc "Print version"
15
+
16
+ def call(*)
17
+ puts Marcdouane::VERSION
18
+ end
19
+ end
20
+
21
+ class Check < Dry::CLI::Command
22
+ desc "Check the Markdown of one or more file"
23
+
24
+ option :verbose,
25
+ type: :boolean,
26
+ default: false,
27
+ desc: "Verbose output"
28
+
29
+ option :config,
30
+ type: :string,
31
+ required: false,
32
+ desc: "Configuration file"
33
+
34
+ argument :files,
35
+ type: :array,
36
+ required: true,
37
+ desc: "Files to check against"
38
+
39
+ def call(files: [], **options)
40
+ if files.empty?
41
+ puts "No files provided."
42
+ return
43
+ end
44
+
45
+ exit_code = 0
46
+
47
+ files.each do |file|
48
+ if !File.exist?(file)
49
+ $stderr.puts "`#{file}' is missing, or not a valid file."
50
+ exit_code = 1
51
+ else
52
+ exit_code = Marcdouane::FileChecker.call(file, options)
53
+ end
54
+ end
55
+
56
+ exit(exit_code)
57
+ end
58
+ end
59
+
60
+
61
+ register "version", Version, aliases: ["v", "-v", "--version"]
62
+ register "check", Check
63
+ end
64
+ end
65
+ end
66
+
67
+ Dry::CLI.new(Marcdouane::CLI::Commands).call
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "debug"
4
+ require "yaml"
5
+
6
+ require_relative "rules"
7
+
8
+ module Marcdouane
9
+ class FileChecker
10
+ class << self
11
+ def call(file, options)
12
+ verbose = options.fetch(:verbose)
13
+
14
+ parse_config!(options[:config])
15
+
16
+ puts "Checking `#{file}'..." if verbose
17
+
18
+ exit_code = 0
19
+
20
+ rules.each do |rule|
21
+ rule.new(file, options).check!
22
+ rescue Marcdouane::Error => e
23
+ $stderr.puts("#{file}:#{e.line_number}: #{e.message}")
24
+
25
+ exit_code = 1
26
+ end
27
+
28
+ puts "Done." if verbose
29
+
30
+ exit_code
31
+ end
32
+
33
+ def parse_config!(path)
34
+ return if path.nil?
35
+
36
+ config = YAML.load_file(path)
37
+
38
+ config.each do |klass, hash|
39
+ hash.each do |key, value|
40
+ rule_class = "Marcdouane::Rules::#{klass}"
41
+
42
+ const_get(rule_class).class_eval do
43
+ self.config[key.to_sym] = value
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ def rules
50
+ Marcdouane::Rules::Rule.subclasses
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marcdouane
4
+ module Rules
5
+ class ConsistentHeaderStyle < Rule
6
+ ERROR_MESSAGE = "Use a unique, consistent header style"
7
+
8
+ def check!
9
+ reference_style, style = nil
10
+
11
+ source = @markdown.source.lines.dup
12
+
13
+ @markdown.on(:heading) do |header|
14
+ line_number = line_number_from_byte_range(header.byte_range)
15
+
16
+ raw_line = source[line_number]
17
+
18
+ if raw_line.start_with?("#")
19
+ style = :normal
20
+ else
21
+ style = :underline
22
+ end
23
+
24
+ reference_style ||= style
25
+
26
+ if reference_style != style
27
+ error!(line_number)
28
+ end
29
+ end
30
+
31
+ @markdown.walk
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marcdouane
4
+ module Rules
5
+ # Ensure that every child header is always a direct descendant of
6
+ # the previous header (i.e its level increments by 1).
7
+ class EnsureHeadersCascade < Rule
8
+ ERROR_MESSAGE = "Header levels should increment one at a time"
9
+
10
+ def check!
11
+ previous_level = nil
12
+
13
+ @markdown.on(:heading) do |header|
14
+ previous_level ||= header.level
15
+
16
+ if header.level > previous_level && header.level != previous_level + 1
17
+ error!(line_number_from_byte_range(header.byte_range))
18
+ else
19
+ previous_level = header.level
20
+ end
21
+ end
22
+
23
+ @markdown.walk
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ URI_REGEXP = URI::RFC2396_PARSER.make_regexp
6
+
7
+ module Marcdouane
8
+ module Rules
9
+ # Ensure the line-length does not go over the default (80)
10
+ # character limit. Link references are not accounted for.
11
+ class LineLength < Rule
12
+ ERROR_MESSAGE = "Line-length is over %s characters"
13
+
14
+ setting :maximum_line_length, default: 80, reader: true
15
+
16
+ def check!
17
+ @markdown.source.lines.each_with_index do |line, index|
18
+ if line.match?(/^\[.*\]: #{URI_REGEXP}$/)
19
+ next
20
+ elsif line.length > self.class.maximum_line_length
21
+ error!(
22
+ index,
23
+ ERROR_MESSAGE % self.class.maximum_line_length
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marcdouane
4
+ module Rules
5
+ # Ensure there are no consecutive blank lines
6
+ class NoConsecutiveBlankLines < Rule
7
+ ERROR_MESSAGE = "Consecutive blank lines"
8
+
9
+ def check!
10
+ @markdown
11
+ .source
12
+ .lines
13
+ .each_cons(2)
14
+ .each_with_index do |pair, index|
15
+ if pair.map(&:strip).all?(&:empty?)
16
+ error!(index + 2)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "inkmark"
5
+
6
+ module Marcdouane
7
+ module Rules
8
+ # Rule is the base class to regroup all rules. It is initialized
9
+ # with a file path and some options forwarded from the CLI
10
+ # invocation.
11
+ #
12
+ # Subclasses must implement `check!` and provide an
13
+ # ERROR_MESSAGE when the check fails.
14
+ class Rule
15
+ extend Dry::Configurable
16
+
17
+ attr_reader :file, :options, :markdown
18
+
19
+ def initialize(file, options)
20
+ @file = file
21
+ @options = options
22
+ @markdown = Inkmark.new(
23
+ File.read(file),
24
+ options: {
25
+ frontmatter: true
26
+ }
27
+ )
28
+ end
29
+
30
+ def line_number_from_byte_range(range)
31
+ File.binread(file, range.first).count("\n")
32
+ end
33
+
34
+ def identifier
35
+ self.class.to_s.split("::").last
36
+ end
37
+
38
+ # Produces an error that will be collected and output by the
39
+ # CLI. It must be called with the 0-indexed line-number, and an
40
+ # optional `message` override instead of the class's
41
+ # ERROR_MESSAGE.
42
+ def error!(machine_line_number, message = nil)
43
+ msg = message || self.class.const_get("ERROR_MESSAGE")
44
+
45
+ raise Marcdouane::Error.new(
46
+ "[#{identifier}] #{msg}",
47
+ machine_line_number + 1
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marcdouane
4
+ module Rules
5
+ # Ensure the first line, frontmatter or not, is a top-level
6
+ # header.
7
+ class StartWithTopLevelHeader < Rule
8
+ ERROR_MESSAGE = "The file should start with a top-level header"
9
+
10
+ def check!
11
+ sections = @markdown.chunks_by_heading
12
+
13
+ if sections.empty? || sections.first[:level] != 1
14
+ error!(0)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rules/rule"
4
+
5
+ require_relative "rules/ensure_headers_cascade"
6
+ require_relative "rules/line_length"
7
+ require_relative "rules/no_consecutive_blank_lines"
8
+ require_relative "rules/start_with_top_level_header"
9
+ require_relative "rules/consistent_header_style"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marcdouane
4
+ VERSION = "0.1.0"
5
+ end
data/lib/marcdouane.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "marcdouane/version"
4
+ require_relative "marcdouane/file_checker"
5
+
6
+ module Marcdouane
7
+ class Error < StandardError
8
+ attr_reader :msg, :line_number
9
+
10
+ def initialize(msg, line_number)
11
+ @msg = msg
12
+ @line_number = line_number
13
+
14
+ super(@msg)
15
+ end
16
+ end
17
+ end
data/logo.jpeg ADDED
Binary file
@@ -0,0 +1,4 @@
1
+ module Marcdouane
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: marcdouane
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stéphane Maniaci
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dry-configurable
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: inkmark
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.3
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.3
54
+ - !ruby/object:Gem::Dependency
55
+ name: yaml
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.4'
68
+ description: Marcdouane lints Markdown files and can be easily extended to fit your
69
+ needs.
70
+ email:
71
+ - stephane.maniaci@gmail.com
72
+ executables:
73
+ - marcdouane
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE.txt
78
+ - README.md
79
+ - Rakefile
80
+ - bin/marcdouane
81
+ - lib/marcdouane.rb
82
+ - lib/marcdouane/file_checker.rb
83
+ - lib/marcdouane/rules.rb
84
+ - lib/marcdouane/rules/consistent_header_style.rb
85
+ - lib/marcdouane/rules/ensure_headers_cascade.rb
86
+ - lib/marcdouane/rules/line_length.rb
87
+ - lib/marcdouane/rules/no_consecutive_blank_lines.rb
88
+ - lib/marcdouane/rules/rule.rb
89
+ - lib/marcdouane/rules/start_with_top_level_header.rb
90
+ - lib/marcdouane/version.rb
91
+ - logo.jpeg
92
+ - sig/marcdouane.rbs
93
+ homepage: https://github.com/freesteph/marcdouane
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/freesteph/marcdouane
98
+ source_code_uri: https://github.com/freesteph/marcdouane
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '4.0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 4.0.10
114
+ specification_version: 4
115
+ summary: Marcdouane is a pretty Markdown linter
116
+ test_files: []