mdtoc 0.1.3 → 0.1.4

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 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