mdtoc 0.1.3 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae0f7d7fa8f96a2d68073f0fbc5a0b92f8ac8182b82fbd2a7bcabb074449d525
4
- data.tar.gz: a0af9901b92c93dc3eea21ddae16fa349f7be653945f51e9867d2f69efc1073a
3
+ metadata.gz: 4b54718ab7b2f7bbc8e90ae473318895665986e0a7408674670db03294f12d6e
4
+ data.tar.gz: f1b251a78ba55942f8c85d82b99f614d3b9368541b7dcb1f144d1094e293d01f
5
5
  SHA512:
6
- metadata.gz: 350566fff5655c9ab17502f02992c23d04e23d844cd94a6c25788d9c745142135f3cc2a1d41e7b7cc6a691caf209edfb9f689816d2b3a1379a75f061a756fddc
7
- data.tar.gz: 33af4aeb021e9b61bb111ab2c20904f1af90afb4bf5c62361fea8e08d3bb21fcde68a9dc45b1863035f321db4a23a367898b9648f270931394c76e3437562140
6
+ metadata.gz: 247816469ed9798906c93e6ca0c58bd4b863d379795d9f776b71fc6c7d3835d7eb4ee890372a408ee04981658336a5f924156d85456e66d90793e0d58b7bd9e2
7
+ data.tar.gz: 2c10991bfa7f4b95896d468f803bae94b44899fc695c1113bd6fa6d7d90c784f4303939a7c2637ed70cd6335bf51f66688d6c932b03a6c0d284f5060cc009646
@@ -4,7 +4,6 @@
4
4
  require 'optparse'
5
5
  require 'tempfile'
6
6
  require_relative 'mdtoc/cli'
7
- require_relative 'mdtoc/markdown'
8
7
  require_relative 'mdtoc/node'
9
8
  require_relative 'mdtoc/writer'
10
9
 
@@ -14,8 +13,8 @@ module Mdtoc
14
13
 
15
14
  sig { params(args: T::Array[String]).void }
16
15
  def main(args)
17
- options = Mdtoc::CLI::Options.for_args(args)
18
- toc = render_toc(options.paths)
16
+ options = Mdtoc::CLI.parse(args)
17
+ toc = Mdtoc::Node.render(options.paths)
19
18
  unless options.output
20
19
  puts toc
21
20
  return
@@ -23,17 +22,6 @@ module Mdtoc
23
22
 
24
23
  Mdtoc::Writer.write(toc, T.must(options.output), options.append, options.create)
25
24
  end
26
-
27
- private
28
-
29
- sig { params(paths: T::Array[String]).returns(String) }
30
- def render_toc(paths)
31
- paths
32
- .map { |path| Mdtoc::Node.for_path(path).headers }
33
- .flatten(1)
34
- .map(&:to_s)
35
- .join("\n")
36
- end
37
25
  end
38
26
  end
39
27
 
@@ -4,26 +4,14 @@
4
4
  require 'optparse'
5
5
  require 'sorbet-runtime'
6
6
  require 'tempfile'
7
- require_relative 'markdown'
8
- require_relative 'node'
9
- require_relative 'writer'
10
7
 
11
8
  module Mdtoc
12
9
  module CLI
13
- class Options < T::Struct
10
+ class << self
14
11
  extend T::Sig
15
12
 
16
- prop :append, T::Boolean, default: false
17
- prop :create, T::Boolean, default: false
18
- prop :output, T.nilable(String)
19
- prop :paths, T::Array[String], default: []
20
-
21
- def []=(key, val)
22
- send("#{key}=", val)
23
- end
24
-
25
13
  sig { params(args: T::Array[String]).returns(Options) }
26
- def self.for_args(args)
14
+ def parse(args)
27
15
  parser = OptionParser.new do |parser_|
28
16
  parser_.banner = "Usage: #{parser_.program_name} [options] files or directories..."
29
17
  parser_.on('-h', '--help', 'Show this message') do
@@ -44,5 +32,19 @@ module Mdtoc
44
32
  options
45
33
  end
46
34
  end
35
+
36
+ class Options < T::Struct
37
+ extend T::Sig
38
+
39
+ prop :append, T::Boolean, default: false
40
+ prop :create, T::Boolean, default: false
41
+ prop :output, T.nilable(String)
42
+ prop :paths, T::Array[String], default: []
43
+
44
+ sig { params(key: Symbol, val: T.untyped).returns(T.untyped) }
45
+ def []=(key, val)
46
+ send("#{key}=", val)
47
+ end
48
+ end
47
49
  end
48
50
  end
@@ -0,0 +1,41 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module Mdtoc
7
+ module Markdown
8
+ class Header
9
+ extend T::Sig
10
+
11
+ sig { params(depth: Integer, label: String, url: String).void }
12
+ def initialize(depth, label, url)
13
+ if depth < 0
14
+ raise ArgumentError, "Header depth must be >= 0, but was #{depth}"
15
+ end
16
+ @depth = depth
17
+ @label = label.strip.gsub(/\s+/, ' ')
18
+ @url = url
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def to_s
23
+ prefix = ' ' * 2 * @depth
24
+ "#{prefix}* [#{@label}](#{@url})"
25
+ end
26
+
27
+ sig { params(relative_to_depth: Integer).returns(T::Boolean) }
28
+ def top_level?(relative_to_depth)
29
+ @depth == relative_to_depth
30
+ end
31
+ end
32
+
33
+ class HeaderWithFragment < Header
34
+ sig { params(depth: Integer, label: String, url: String).void }
35
+ def initialize(depth, label, url)
36
+ url = "#{url}##{label.strip.downcase.tr(' ', '-').gsub(/[^\w\-]/, '')}"
37
+ super
38
+ end
39
+ end
40
+ end
41
+ end
@@ -2,42 +2,10 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'sorbet-runtime'
5
+ require_relative 'header'
5
6
 
6
7
  module Mdtoc
7
8
  module Markdown
8
- class Header
9
- extend T::Sig
10
-
11
- sig { params(depth: Integer, label: String, url: String).void }
12
- def initialize(depth, label, url)
13
- if depth < 0
14
- raise ArgumentError, "Header depth must be >= 0, but was #{depth}"
15
- end
16
- @depth = depth
17
- @label = label.strip.gsub(/\s+/, ' ')
18
- @url = url
19
- end
20
-
21
- sig { params(relative_to_depth: Integer).returns(T::Boolean) }
22
- def top_level?(relative_to_depth)
23
- @depth == relative_to_depth
24
- end
25
-
26
- sig { returns(String) }
27
- def to_s
28
- prefix = ' ' * 2 * @depth
29
- "#{prefix}* [#{@label}](#{@url})"
30
- end
31
- end
32
-
33
- class HeaderWithFragment < Header
34
- sig { params(depth: Integer, label: String, url: String).void }
35
- def initialize(depth, label, url)
36
- url = "#{url}##{label.downcase.strip.gsub(/ /, '-').gsub(/[^\w\-_ ]/, '')}"
37
- super
38
- end
39
- end
40
-
41
9
  class Parser
42
10
  extend T::Sig
43
11
 
@@ -65,7 +33,7 @@ module Mdtoc
65
33
 
66
34
  private
67
35
 
68
- sig { params(line: String).returns(Header) }
36
+ sig { params(line: String).returns(HeaderWithFragment) }
69
37
  def header(line)
70
38
  m = T.must(line.strip.match(/^(#+)\s*(.*)$/))
71
39
  num_hashes = m[1]&.count('#') || 1
@@ -3,7 +3,8 @@
3
3
 
4
4
  require 'pathname'
5
5
  require 'sorbet-runtime'
6
- require_relative 'markdown'
6
+ require_relative 'markdown/header'
7
+ require_relative 'markdown/parser'
7
8
 
8
9
  module Mdtoc
9
10
  class Node
@@ -11,13 +12,24 @@ module Mdtoc
11
12
  extend T::Sig
12
13
  abstract!
13
14
 
14
- sig { params(path: String, depth: Integer).returns(Node) }
15
- def self.for_path(path, depth = 0)
16
- # Ensure that `path` is a relative path, so that all links are relative and therefore portable.
17
- path = Pathname.new(path)
18
- path = path.relative_path_from(Dir.pwd) if path.absolute?
19
- path = path.to_s
20
- File.directory?(path) ? DirNode.new(path, depth) : FileNode.new(path, depth)
15
+ class << self
16
+ extend T::Sig
17
+
18
+ sig { params(path: String, depth: Integer).returns(Node) }
19
+ def for_path(path, depth = 0)
20
+ # Ensure that `path` is a relative path, so that all links are relative and therefore portable.
21
+ path = Pathname.new(path)
22
+ path = path.relative_path_from(Dir.pwd) if path.absolute?
23
+ path = path.to_s
24
+ File.directory?(path) ? DirNode.new(path, depth) : FileNode.new(path, depth)
25
+ end
26
+
27
+ sig { params(paths: T::Array[String]).returns(String) }
28
+ def render(paths)
29
+ paths
30
+ .flat_map { |path| for_path(path).headers }
31
+ .join("\n")
32
+ end
21
33
  end
22
34
 
23
35
  sig { params(path: String, depth: Integer).void }
@@ -26,13 +38,13 @@ module Mdtoc
26
38
  @depth = depth
27
39
  end
28
40
 
41
+ sig { abstract.returns(T::Array[Mdtoc::Markdown::Header]) }
42
+ def headers; end
43
+
29
44
  sig { returns(String) }
30
45
  def label
31
46
  File.basename(@path, File.extname(@path)).gsub(/_+/, ' ').gsub(/\s+/, ' ').capitalize
32
47
  end
33
-
34
- sig { abstract.returns(T::Array[Mdtoc::Markdown::Header]) }
35
- def headers; end
36
48
  end
37
49
 
38
50
  class DirNode < Node
@@ -41,11 +53,9 @@ module Mdtoc
41
53
  readme_path = T.let(nil, T.nilable(String))
42
54
  child_headers = Dir
43
55
  .each_child(@path)
44
- .map { |path| File.join(@path, path) }
45
- .reject { |path| readme_path = path if File.basename(path).downcase == 'readme.md' }
56
+ .reject { |path| readme_path = File.join(@path, path) if path.casecmp?('readme.md') }
46
57
  .sort!
47
- .map { |path| Node.for_path(path, @depth + 1).headers }
48
- .flatten(1)
58
+ .flat_map { |path| Node.for_path(File.join(@path, path), @depth + 1).headers }
49
59
  return child_headers unless readme_path
50
60
 
51
61
  # Include the headers from the README at the beginning.
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mdtoc
5
- VERSION = '0.1.3'
5
+ VERSION = '0.1.4'
6
6
  end
@@ -28,7 +28,7 @@ module Mdtoc
28
28
  f = File.open(path)
29
29
  rescue
30
30
  # If File.open failed because the file didn't exist, then we know that --create
31
- # was specified due to the validation in self.validate_path.
31
+ # was specified due to the validation in validate_path.
32
32
  return "#{toc}\n"
33
33
  end
34
34
  begin
@@ -51,16 +51,14 @@ module Mdtoc
51
51
 
52
52
  sig { params(path: String, create: T::Boolean).void }
53
53
  def validate_path(path, create)
54
- if path
55
- if File.exist?(path)
56
- unless File.file?(path)
57
- warn("--output PATH \"#{path}\" is not a regular file")
58
- exit
59
- end
60
- elsif !create
61
- warn("--output PATH \"#{path}\" does not exist. Specify --create to create it.")
54
+ if File.exist?(path)
55
+ unless File.file?(path)
56
+ warn("--output PATH \"#{path}\" is not a regular file")
62
57
  exit
63
58
  end
59
+ elsif !create
60
+ warn("--output PATH \"#{path}\" does not exist. Specify --create to create it.")
61
+ exit
64
62
  end
65
63
  end
66
64
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mdtoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - andornaut
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-02 00:00:00.000000000 Z
11
+ date: 2020-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -122,7 +122,101 @@ dependencies:
122
122
  - - ">="
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0'
125
- description:
125
+ description: |
126
+ # mdtoc - Markdown Table of Contents
127
+
128
+ Read Markdown files and output a table of contents.
129
+
130
+ ## Installation
131
+
132
+ Requirements:
133
+
134
+ * [Ruby](https://www.ruby-lang.org/en/) (see [.ruby-version](./.ruby-version))
135
+
136
+ ```
137
+ $ gem install mdtoc
138
+ ```
139
+
140
+ ## Usage
141
+
142
+ ```
143
+ $ mdtoc --help
144
+ Usage: mdtoc [options] files or directories...
145
+ -h, --help Show this message
146
+ -o, --output PATH Update a table of contents in the file at PATH
147
+ -a, --[no-]append Append to the --output file if a <!-- mdtoc --> tag isn't found
148
+ -c, --[no-]create Create the --output file if it does not exist
149
+ ```
150
+
151
+ 1. Add a `<!-- mdtoc -->` tag to a Markdown file.
152
+ ```
153
+ $ echo '<!-- mdtoc -->` >> README.md
154
+ ```
155
+ 2. Run `mdtoc` and specify input files or directories (eg. the "test/samples" directory) and an output file (eg. "README.md").
156
+ ```
157
+ $ mdtoc -aco README.md test/samples
158
+ ```
159
+
160
+ ## Example Rakefile
161
+
162
+ Run the [rake](https://github.com/ruby/rake) "mdtoc" task to update a table of contents.
163
+ See [andornaut/til](https://github.com/andornaut/til) for an example.
164
+
165
+ ```
166
+ task default: %w[mdtoc]
167
+
168
+ desc 'Update Markdown table of contents and push changes to the git repository'
169
+ task :mdtoc do |t|
170
+ command = <<~END
171
+ set -e
172
+ git pull
173
+ if [ -n "$(git diff --name-only --diff-filter=U)" ]; then
174
+ echo 'Error: conflicts exist' >&2
175
+ exit 1
176
+ fi
177
+ mdtoc --append --create --output README.md docs/
178
+ git add *.md **/*.md
179
+ git commit -m 'Update TOC'
180
+ git push
181
+ END
182
+ %x|#{command}|
183
+ end
184
+ ```
185
+
186
+ ## Development
187
+
188
+ ### Installation
189
+
190
+ Requirements:
191
+
192
+ * [Bundler](https://bundler.io/)
193
+
194
+ ```
195
+ # Install dependencies
196
+ $ bundle
197
+ ```
198
+
199
+ ### Usage
200
+
201
+ ```
202
+ # List rake tasks
203
+ $ rake -T
204
+ rake build # Build mdtoc-0.0.2.gem into the pkg directory
205
+ rake default # Run the build, rubocop:auto_correct, sorbet and test tasks
206
+ rake install # Build and install mdtoc-0.0.2.gem into system gems
207
+ rake install:local # Build and install mdtoc-0.0.2.gem into system gems without...
208
+ rake release[remote] # Create tag v0.0.2 and build and push mdtoc-0.0.2.gem to ru...
209
+ rake rubocop # Run RuboCop
210
+ rake rubocop:auto_correct # Auto-correct RuboCop offenses
211
+ rake sorbet # Run the Sorbet type checker
212
+ rake test # Run tests
213
+
214
+ # Run mdtoc with test inputs
215
+ $ ruby -Ilib bin/mdtoc test/samples
216
+
217
+ # Run mdtoc with test inputs, and write to a newly created output file
218
+ $ f=$(mktemp) && ruby -Ilib bin/mdtoc -aco ${f} test/samples ; cat ${f}
219
+ ```
126
220
  email:
127
221
  executables:
128
222
  - mdtoc
@@ -132,19 +226,11 @@ files:
132
226
  - bin/mdtoc
133
227
  - lib/mdtoc.rb
134
228
  - lib/mdtoc/cli.rb
135
- - lib/mdtoc/markdown.rb
229
+ - lib/mdtoc/markdown/header.rb
230
+ - lib/mdtoc/markdown/parser.rb
136
231
  - lib/mdtoc/node.rb
137
232
  - lib/mdtoc/version.rb
138
233
  - lib/mdtoc/writer.rb
139
- - test/samples/README.md
140
- - test/samples/a/c.md
141
- - test/samples/a/d/f.md
142
- - test/samples/a/e.md
143
- - test/samples/a/g/README.md
144
- - test/samples/a/g/h.md
145
- - test/samples/a/readme.md
146
- - test/test_markdown.rb
147
- - test/test_node.rb
148
234
  homepage: https://github.com/andornaut/mdtoc
149
235
  licenses:
150
236
  - MIT
@@ -1,18 +0,0 @@
1
- # Title
2
- intro text
3
- # Ignore this non-title
4
- # ignore this non title
5
- ```
6
- ignore this multi-line code block
7
- ```
8
- ```ignore this inline block```
9
- <!-- ignore this comment -->
10
- ## 2
11
- ### 3
12
-
13
- ## 2
14
- text
15
-
16
- #### 4
17
- text
18
- ## 2
@@ -1,2 +0,0 @@
1
- # c 1
2
- ## c 2
@@ -1 +0,0 @@
1
- ## f 2
@@ -1 +0,0 @@
1
- # e 1
@@ -1 +0,0 @@
1
- # README 1 for g
@@ -1 +0,0 @@
1
- # h 1
@@ -1,4 +0,0 @@
1
- # readme 1
2
- ## readme 2
3
- ### readme 3
4
- #### readme 4
@@ -1,127 +0,0 @@
1
- # typed: false
2
- # frozen_string_literal: true
3
-
4
- require "minitest/autorun"
5
- require "mdtoc/markdown"
6
-
7
- class TestHeader < Minitest::Test
8
- def test_fragment_normalization
9
- sample = [
10
- 'Spaces 1 space',
11
- 'Spaces 2 spaces',
12
- "Spaces\t1 tab",
13
- "Spaces\t\t2 tabs",
14
- ' Spaces leading and trailing ',
15
- 'Numbers 1234567890',
16
- 'Symbols -=~!@#$%^&',
17
- 'Symbols *()_+',
18
- 'Symbols <>?:"{}|[]\;\',./',
19
- ]
20
- expecteds = [
21
- '/a#spaces-1-space',
22
- '/a#spaces--2-spaces',
23
- '/a#spaces1-tab',
24
- '/a#spaces2-tabs',
25
- '/a#spaces-leading-and-trailing',
26
- '/a#numbers-1234567890',
27
- '/a#symbols--',
28
- '/a#symbols-_',
29
- '/a#symbols-',
30
- ]
31
- actuals = sample.map do |label|
32
- Mdtoc::Markdown::HeaderWithFragment.new(1, label, '/a').instance_variable_get(:@url)
33
- end
34
-
35
- expecteds.zip(actuals).each { |expected, actual| assert_equal(expected, actual) }
36
- end
37
-
38
- def test_invalid_depth
39
- assert_raises(ArgumentError) do
40
- Mdtoc::Markdown::Header.new(-1, 'a', '/a')
41
- end
42
- end
43
-
44
- def test_label_normalization
45
- sample = [
46
- ' strip ',
47
- "squeeze internal \t spaces",
48
- 'Don\'t change "#1?|!@#$%^&*()+ 2--',
49
- ]
50
- expecteds = [
51
- 'strip',
52
- "squeeze internal spaces",
53
- 'Don\'t change "#1?|!@#$%^&*()+ 2--',
54
- ]
55
- actuals = sample.map { |label| Mdtoc::Markdown::Header.new(0, label, '/a').instance_variable_get(:@label) }
56
-
57
- expecteds.zip(actuals).each { |expected, actual| assert_equal(expected, actual) }
58
- end
59
-
60
- def test_to_s_prefix
61
- str = Mdtoc::Markdown::Header.new(3, 'a', '/a').to_s
62
-
63
- assert_equal(' * [a](/a)', str)
64
- end
65
-
66
- def test_to_s_with_fragment
67
- str = Mdtoc::Markdown::HeaderWithFragment.new(0, 'a', '/a').to_s
68
-
69
- assert_equal('* [a](/a#a)', str)
70
- end
71
-
72
- def test_to_s_without_fragment
73
- str = Mdtoc::Markdown::Header.new(0, 'a', '/a').to_s
74
-
75
- assert_equal('* [a](/a)', str)
76
- end
77
- end
78
-
79
- class TestParser < Minitest::Test
80
- def test_skips_multiline_code_blocks
81
- parser = Mdtoc::Markdown::Parser.new(0, '/')
82
- sample = <<~END
83
- # title
84
- ```
85
- code
86
- # code
87
- ```
88
- END
89
-
90
- headers = parser.headers(sample.each_line)
91
-
92
- assert_equal(1, headers.size)
93
- assert_equal('title', headers[0].instance_variable_get(:@label))
94
- end
95
-
96
- def test_skips_inline_code_blocks
97
- parser = Mdtoc::Markdown::Parser.new(0, '/')
98
- sample = <<~END
99
- ```code #```
100
- # Title
101
- ```# code```
102
- ```code```#
103
- END
104
-
105
- headers = parser.headers(sample.each_line)
106
-
107
- assert_equal(1, headers.size)
108
- assert_equal(headers[0].instance_variable_get(:@label), 'Title')
109
- end
110
-
111
- def test_depth
112
- parser = Mdtoc::Markdown::Parser.new(10, '/')
113
- sample = <<~END
114
- # 1
115
- ## 2
116
- ### 3
117
- #### 4
118
- END
119
-
120
- headers = parser.headers(sample.each_line)
121
-
122
- assert_equal(4, headers.size)
123
- (0..3).each do |i|
124
- assert_equal(10 + i, headers[i].instance_variable_get(:@depth))
125
- end
126
- end
127
- end
@@ -1,50 +0,0 @@
1
- # typed: false
2
- # frozen_string_literal: true
3
-
4
- require "minitest/autorun"
5
- require "mdtoc/node"
6
-
7
- class TestNode < Minitest::Test
8
- SAMPLE_DIR = File.join(File.dirname(__FILE__), 'samples')
9
-
10
- def test_dir
11
- expected = <<~END
12
- * [readme 1](test/samples/a/readme.md#readme-1)
13
- * [readme 2](test/samples/a/readme.md#readme-2)
14
- * [readme 3](test/samples/a/readme.md#readme-3)
15
- * [readme 4](test/samples/a/readme.md#readme-4)
16
- * [c 1](test/samples/a/c.md#c-1)
17
- * [c 2](test/samples/a/c.md#c-2)
18
- * [F](test/samples/a/d/f.md)
19
- * [f 2](test/samples/a/d/f.md#f-2)
20
- * [e 1](test/samples/a/e.md#e-1)
21
- * [README 1 for g](test/samples/a/g/README.md#readme-1-for-g)
22
- * [h 1](test/samples/a/g/h.md#h-1)
23
- END
24
- node = Mdtoc::Node.for_path(sample_path('a'))
25
- actual = node.headers.join("\n") + "\n"
26
-
27
- assert_equal(expected, actual)
28
- end
29
-
30
- def test_file
31
- expected = <<~END
32
- * [Title](test/samples/README.md#title)
33
- * [2](test/samples/README.md#2)
34
- * [3](test/samples/README.md#3)
35
- * [2](test/samples/README.md#2)
36
- * [4](test/samples/README.md#4)
37
- * [2](test/samples/README.md#2)
38
- END
39
- node = Mdtoc::Node.for_path(sample_path('README.md'))
40
- actual = node.headers.join("\n") + "\n"
41
-
42
- assert_equal(expected, actual)
43
- end
44
-
45
- private
46
-
47
- def sample_path(path)
48
- File.join('test/samples', path)
49
- end
50
- end