hiki2md 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd9f235518be1faf5230ff7f4739a1fb311727ded398fe7c44f24b459c3ec3a0
4
- data.tar.gz: 47ae584b929f160ae9a680994dfebdacfc1fe12e7c9cb52470ae900fc0d8482a
3
+ metadata.gz: 9b4d6cfffb000735e6110b7f97177fed422bd998b1ea8d07672ee0965f0232f8
4
+ data.tar.gz: 92ac94a769691e6bd4a7662bb0b33adcc7b6036d720f5d7171b21c08511a6954
5
5
  SHA512:
6
- metadata.gz: b0e0d03d869dfd64587ae2b782e56d6bf5fa88e4bb8bd0eba6736b00ff06c5f0b929e41577fdbeb4cdb3a01e272400b94e95ddf3633c2ad7a3be6f7d3e37d8be
7
- data.tar.gz: 98c7f8cda9a0d088cf26c4a4d23e340f5fbcb7b23e3cfe6e47794170166c6511c6c8324f798d9a07e8ea2a2f227c03670670b9f91fce4a6d8c07c58a65994727
6
+ metadata.gz: fc77e072352a1a301871154af34120ccf5a32bb0857019e92132a0867a972c7ff918f53915a062fc3317b1c702b1512dfdc25889f9f590c0b18f28c883c80820
7
+ data.tar.gz: 3c6df53740b623b39bb5f20e11d314a6f6de3f251cfced2e7f7bb596d98f2a67d4cf04573bb58f3179510ee80d1ac1f018489fc7bc6ed81cc9ae3d82084a66be
data/.travis.yml CHANGED
@@ -1,4 +1,6 @@
1
1
  language: ruby
2
+ dist: jammy
2
3
  rvm:
3
- - 2.2.3
4
- before_install: gem install bundler -v 1.10.6
4
+ - 3.1
5
+ - 3.4
6
+ before_install: gem install bundler -v 2.6.8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hiki2md (0.1.3)
4
+ hiki2md (0.3.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -6,6 +6,8 @@ Converter of Hiki to Markdown.
6
6
 
7
7
  ## Installation
8
8
 
9
+ Hiki2md requires Ruby 2.2 or newer.
10
+
9
11
  Add this line to your application's Gemfile:
10
12
 
11
13
  ```ruby
@@ -22,7 +24,87 @@ Or install it yourself as:
22
24
 
23
25
  ## Usage
24
26
 
25
- TODO: Write usage instructions here
27
+ ### Ruby
28
+
29
+ ```ruby
30
+ require "hiki2md"
31
+
32
+ converter = Hiki2md.new(
33
+ interwiki_map: {
34
+ "Google" => "https://www.google.com/search?q=",
35
+ "Issues" => "https://example.com/issues/$1"
36
+ },
37
+ use_wiki_name: true,
38
+ preserve_plugins: false
39
+ )
40
+
41
+ markdown = converter.convert(hiki_source)
42
+ ```
43
+
44
+ Internal page links receive a `.md` extension. InterWiki values either have
45
+ the encoded page name appended, or can contain `$1` where the page name should
46
+ be inserted.
47
+
48
+ ### Command line
49
+
50
+ ```console
51
+ $ hiki2md [options] input.hiki
52
+ ```
53
+
54
+ Options:
55
+
56
+ - `--interwiki-map PATH`: load InterWiki prefixes from a YAML mapping.
57
+ - `--[no-]wiki-name`: enable or disable automatic WikiName links.
58
+ - `--preserve-plugins`: retain plugins as escaped Markdown text instead of
59
+ removing them.
60
+
61
+ ### Supported Hiki syntax
62
+
63
+ The converter targets Hiki's default style and emits GitHub Flavored Markdown
64
+ (GFM).
65
+
66
+ | Hiki | Output |
67
+ | --- | --- |
68
+ | `!` through `!!!!!` | headings 2 through 6 |
69
+ | `*` / `#` | nested unordered / ordered lists |
70
+ | `''text''` / `'''text'''` | emphasis / strong emphasis |
71
+ | `==text==` / double-backtick text | strikethrough / inline code |
72
+ | `[[Page]]` / `[[label\|target]]` | page, URL, and InterWiki links |
73
+ | image URLs | Markdown images, including basename or labeled alt text |
74
+ | leading space or tab, `<<<` ... `>>>` | fenced code blocks |
75
+ | `""` | recursive blockquotes |
76
+ | `:term:description` | HTML definition lists |
77
+ | `\|\|cell` | raw HTML tables |
78
+ | `----` | horizontal rules |
79
+ | `// comment` | removed comments |
80
+
81
+ Hiki permits tables without a header row, while GFM pipe tables require one.
82
+ All Hiki tables are therefore emitted as raw HTML so ordinary `td` cells,
83
+ explicit `!` header cells, and `^` / `>` row and column spans retain their
84
+ meaning.
85
+
86
+ Hiki plugins are executable Ruby extensions and cannot be converted
87
+ generically. Plugin calls are removed by default. Use `preserve_plugins: true`
88
+ or `--preserve-plugins` to keep them as escaped Markdown text for manual
89
+ migration. Plugin-looking text inside preformatted blocks is always kept
90
+ literally.
91
+
92
+ ### Version 0.3 output changes
93
+
94
+ Version 0.3 favors the rendered meaning of Hiki over preserving the exact
95
+ Markdown source produced by 0.2. In particular, all tables now use raw HTML,
96
+ ordered-list indentation follows CommonMark, and Markdown-looking source text
97
+ is escaped when Hiki treats it as plain text. The legacy public `make_matrix`
98
+ and `make_table` helpers retain their 0.2 behavior.
99
+
100
+ Lists deeper than 100 levels use raw HTML to keep output growth bounded.
101
+ Uniform blockquotes may be arbitrarily deep; blocks containing more than 100
102
+ distinct quote depths raise `ArgumentError` instead of risking stack or CPU
103
+ exhaustion.
104
+
105
+ See Hiki's
106
+ [TextFormattingRules](https://hikiwiki.org/en/TextFormattingRules.html) for
107
+ the source syntax.
26
108
 
27
109
  ## Development
28
110
 
@@ -40,4 +122,3 @@ The gem is available as open source under the terms of the [MIT License](http://
40
122
  ## Inspired by
41
123
 
42
124
  https://github.com/masasuzu/p5-App-hiki2md
43
-
data/exe/hiki2md CHANGED
@@ -4,6 +4,8 @@ require 'optparse'
4
4
  require 'yaml'
5
5
 
6
6
  interwiki_map = {}
7
+ use_wiki_name = true
8
+ preserve_plugins = false
7
9
 
8
10
  OptionParser.new do |opts|
9
11
  opts.banner = "Usage: hiki2md [options] <file>"
@@ -15,6 +17,14 @@ OptionParser.new do |opts|
15
17
  raise IOError.new("InterWiki map file not found: #{path}")
16
18
  end
17
19
  end
20
+
21
+ opts.on("--[no-]wiki-name", "Enable or disable WikiName autolinking") do |enabled|
22
+ use_wiki_name = enabled
23
+ end
24
+
25
+ opts.on("--preserve-plugins", "Keep Hiki plugins as escaped Markdown text") do
26
+ preserve_plugins = true
27
+ end
18
28
  end.parse!
19
29
 
20
30
  if ARGV.size != 1
@@ -27,5 +37,9 @@ if !File.exist?(input_file)
27
37
  raise IOError.new("no exist #{input_file}")
28
38
  end
29
39
 
30
- hiki2md = Hiki2md.new(interwiki_map: interwiki_map)
31
- puts hiki2md.convert(File.read(input_file))
40
+ hiki2md = Hiki2md.new(
41
+ interwiki_map: interwiki_map,
42
+ use_wiki_name: use_wiki_name,
43
+ preserve_plugins: preserve_plugins
44
+ )
45
+ puts hiki2md.convert(File.read(input_file))
data/hiki2md.gemspec CHANGED
@@ -12,6 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.summary = %q{Converter of Hiki to Markdown}
13
13
  spec.homepage = "http://github.com/kdmsnr/hiki2md"
14
14
  spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.2.0"
15
16
 
16
17
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
18
  spec.bindir = "exe"
@@ -1,3 +1,3 @@
1
1
  class Hiki2md
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/hiki2md.rb CHANGED
@@ -1,260 +1,913 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  require 'hiki2md/version'
3
3
  require 'cgi'
4
+ require 'thread'
4
5
 
5
6
  class Hiki2md
6
- def initialize(interwiki_map: {})
7
+ IMAGE_EXTENSIONS = %w[.jpg .jpeg .gif .png .bmp .svg].freeze
8
+ UNSAFE_URI_SCHEMES = %w[javascript data vbscript].freeze
9
+ MAX_MARKDOWN_LIST_DEPTH = 100
10
+ MAX_BLOCKQUOTE_RECURSION = 100
11
+ PLUGIN_TOKEN_RE = /\0(\d+)\0/
12
+ BRACKET_LINK_RE = /\[\[.+?\]\]/
13
+ URI_RE = /(?:https?|ftp|file|mailto):[A-Za-z0-9;\/?:@&=+$,\-_.!~*'()#%]+/
14
+ WIKI_NAME_RE = /\b(?:[A-Z]+[a-z\d]+){2,}\b/
15
+ MODIFIER_RE = /'''.+?'''|''.+?''|==.+?==|``.+?``/
16
+ INLINE_RE = Regexp.union(
17
+ /\0\d+\0/,
18
+ BRACKET_LINK_RE,
19
+ URI_RE,
20
+ MODIFIER_RE,
21
+ /\^?#{WIKI_NAME_RE}/
22
+ )
23
+
24
+ def initialize(interwiki_map: {}, use_wiki_name: true, preserve_plugins: false)
7
25
  @interwiki_map = interwiki_map
26
+ @use_wiki_name = use_wiki_name
27
+ @preserve_plugins = preserve_plugins
28
+ @conversion_mutex = Mutex.new
8
29
  end
9
30
 
10
- def convert(lines)
11
- @outputs = []
31
+ def convert(source)
32
+ @conversion_mutex.synchronize do
33
+ normalized = source.to_s.gsub(/\r\n?/, "\n")
34
+ nul_replacement = normalized.encoding == Encoding::UTF_8 ? "\uFFFD" : '?'
35
+ normalized = normalized.gsub("\0", nul_replacement)
36
+ @source_template = normalized[0, 0]
37
+ escaped = extract_plugins(normalized)
38
+ convert_lines(escaped.split(/\n/))
39
+ end
40
+ end
41
+
42
+ # Legacy public API. Conversion itself uses the structure-preserving table
43
+ # renderer below, but callers of these helpers retain the previous output.
44
+ def make_matrix(contents)
45
+ matrix = []
46
+ contents.each do |line|
47
+ row = line.split('||')
48
+ row.shift
49
+ matrix << row
50
+ end
12
51
 
13
- @in_plugin_block = false
14
- @in_preformatted_block = false
15
- @in_multiline_preformatted_block = false
16
- @in_table_block = false
17
- @in_dl_block = false
18
- @table_contents = []
52
+ matrix.each_with_index do |line, row_index|
53
+ line.each_with_index do |cell, column_index|
54
+ next unless cell =~ /\^+/
19
55
 
20
- lines.split(/\n/).each do |line|
21
- # プラグイン
22
- if @in_plugin_block
23
- if line =~ /}}\z/
24
- @in_plugin_block = false
56
+ matrix[row_index][column_index] = Regexp.last_match.post_match
57
+ Regexp.last_match.size.times do |offset|
58
+ matrix[row_index + offset + 1] ||= []
59
+ matrix[row_index + offset + 1].insert(column_index, ' ')
25
60
  end
26
- next
27
61
  end
62
+ end
28
63
 
29
- if line =~ /\A{{/
30
- next if line =~ /\A{{.+}}\z/
31
- @in_plugin_block = true
64
+ max_columns = 0
65
+ matrix.each_with_index do |line, row_index|
66
+ column_count = line.size
67
+ target_column = 0
68
+ line.each do |cell|
69
+ if cell =~ />+/
70
+ matrix[row_index][target_column] = Regexp.last_match.post_match
71
+ Regexp.last_match.size.times do
72
+ target_column += 1
73
+ matrix[row_index][target_column] = ''
74
+ end
75
+ column_count += Regexp.last_match.size
76
+ else
77
+ matrix[row_index][target_column] = cell
78
+ target_column += 1
79
+ end
32
80
  end
81
+ max_columns = column_count if column_count > max_columns
82
+ end
33
83
 
34
- # 整形済みテキスト(複数行)
35
- if @in_multiline_preformatted_block
36
- if line =~ /\A>>>/
37
- @in_multiline_preformatted_block = false
38
- @outputs << '```'
84
+ [matrix, max_columns]
85
+ end
86
+
87
+ def make_table(table_contents)
88
+ contents, max_columns = make_matrix(table_contents)
89
+ alignment = '|'
90
+ max_columns.times { alignment << ':----|' }
91
+ alignment << "\n"
92
+ table = "\n"
93
+
94
+ contents.each_with_index do |line, index|
95
+ row = '|'
96
+ line.each { |cell| row << "#{cell}|" }
97
+ table << row << "\n"
98
+ table << alignment if index.zero?
99
+ end
100
+
101
+ table
102
+ end
103
+
104
+ private
105
+
106
+ def convert_lines(lines, blockquote_recursion: 0)
107
+ output = []
108
+ index = 0
109
+
110
+ while index < lines.length
111
+ line = lines[index]
112
+
113
+ case line
114
+ when %r{\A//}
115
+ index += 1
116
+ when /\A<<<\s*(\w+)?/
117
+ block, index = convert_block_pre(lines, index, Regexp.last_match(1))
118
+ output << block
119
+ when /\A[ \t]/
120
+ block, index = convert_indented_pre(lines, index)
121
+ output << block
122
+ when /\A""[ \t]?/
123
+ block, index = convert_blockquote(lines, index, blockquote_recursion)
124
+ output << block
125
+ when /\A[*#]+/
126
+ block, index = convert_list(lines, index)
127
+ output << block
128
+ when /\A\|\|/
129
+ table_lines, index = collect_block(lines, index, /\A\|\|/)
130
+ output << render_table(table_lines)
131
+ when /\A:/
132
+ block, index = convert_definition_list(lines, index)
133
+ output << block
134
+ when /\A----\z/
135
+ output << "\n---\n"
136
+ index += 1
137
+ else
138
+ if plugin_only_line?(line) && !@preserve_plugins
139
+ index += 1
39
140
  next
40
141
  end
41
- @outputs << line
42
- next
43
- end
44
142
 
45
- if line =~ /\A<<<\s*(.*)/
46
- @in_multiline_preformatted_block = true
47
- @outputs << "```#{$1}"
48
- next
143
+ output << convert_regular_line(line)
144
+ index += 1
49
145
  end
146
+ end
50
147
 
51
- # 整形済みテキスト
52
- if @in_preformatted_block
53
- if line =~ /\A[ \t]+/
54
- @outputs << line.strip
55
- next
56
- else
57
- @outputs << "```"
58
- @in_preformatted_block = false
59
- end
148
+ join_output(output)
149
+ end
150
+
151
+ def convert_regular_line(line)
152
+ if /\A(!{1,5})(.*)\z/ =~ line
153
+ level = Regexp.last_match(1).length + 1
154
+ title = strip_hiki_whitespace(Regexp.last_match(2))
155
+ converted_title = escape_markdown_heading_closer(convert_inline(title))
156
+ return "#{'#' * level} #{converted_title}"
157
+ end
158
+
159
+ converted = convert_inline(line.sub(/[ \t]+\z/, ''))
160
+ escape_markdown_block_start(converted)
161
+ end
162
+
163
+ def join_output(chunks)
164
+ chunks.each_with_index.each_with_object(@source_template.dup) do |(chunk, index), joined|
165
+ if index > 0
166
+ previous = chunks[index - 1]
167
+ separator = if chunk.empty? || previous.empty?
168
+ "\n"
169
+ elsif html_block?(chunk) || html_block?(previous) ||
170
+ block_requires_blank_after?(previous)
171
+ "\n\n"
172
+ else
173
+ "\n"
174
+ end
175
+ joined << separator
60
176
  end
177
+ joined << chunk
178
+ end
179
+ end
61
180
 
62
- if line =~ /\A[ \t]+/
63
- @in_preformatted_block = true
64
- @outputs << "```\n#{line.strip}"
181
+ def html_block?(chunk)
182
+ chunk.start_with?('<dl>', '<table>', '<ul>', '<ol>')
183
+ end
184
+
185
+ def block_requires_blank_after?(chunk)
186
+ chunk.start_with?('>', '- ', '1. ')
187
+ end
188
+
189
+ def convert_list(lines, index)
190
+ entries = []
191
+
192
+ while index < lines.length
193
+ line = lines[index]
194
+
195
+ if %r{\A//} =~ line
196
+ index += 1
65
197
  next
66
198
  end
67
199
 
68
- # コメント削除
69
- next if line =~ %r|\A//.*\z|
200
+ match = /\A([*#]+)[ \t]?(.*)\z/.match(line)
201
+ break unless match
70
202
 
71
- # 水平線
72
- if line =~ /\A----\z/
73
- @outputs << "\n---\n"
74
- next
203
+ markers = match[1]
204
+ entries << {
205
+ level: markers.length,
206
+ type: markers.start_with?('*') ? :unordered : :ordered,
207
+ content: strip_hiki_whitespace(match[2])
208
+ }
209
+ index += 1
210
+ end
211
+
212
+ deepest_level = entries.map { |entry| entry[:level] }.max || 0
213
+ converted = if deepest_level > MAX_MARKDOWN_LIST_DEPTH
214
+ render_html_list(entries)
215
+ else
216
+ render_markdown_list(entries)
217
+ end
218
+ [converted, index]
219
+ end
220
+
221
+ def render_markdown_list(entries)
222
+ converted = []
223
+ types = []
224
+
225
+ entries.each do |entry|
226
+ level = entry[:level]
227
+ type = entry[:type]
228
+ previous_depth = types.length
229
+
230
+ if level > types.length
231
+ types << type while types.length < level
232
+ elsif level < types.length
233
+ types = types.first(level)
234
+ else
235
+ types[level - 1] = type
75
236
  end
76
237
 
77
- # 箇条書き
78
- line.gsub! /\A[*]{3} ?/, ' - '
79
- line.gsub! /\A[*]{2} ?/, ' - '
80
- line.gsub! /\A[*] ?/ , '- '
81
-
82
- line.gsub! /\A[#]{3} ?/ , ' 1. '
83
- line.gsub! /\A[#]{2} ?/ , ' 1. '
84
- line.gsub! /\A[#] ?/ , '1. '
85
-
86
- # 見出し
87
- line.gsub! /\A!{5} ?/ , '###### '
88
- line.gsub! /\A!{4} ?/ , '##### '
89
- line.gsub! /\A!{3} ?/ , '#### '
90
- line.gsub! /\A!{2} ?/ , '### '
91
- line.gsub! /\A! ?/ , '## '
92
-
93
- # 引用
94
- line.gsub! /\A""/, '>'
95
-
96
- # リンク
97
- line.gsub! /\[{2}([^\[\]\|]+?)\|([^\[\]\|]+?)\]{2}/, "[\\1](\\2)"
98
-
99
- # 画像 or Wiki内リンク
100
- line.gsub!(/\[{2}(.+?)\]{2}/) do |match|
101
- content = $1
102
- if content =~ /\Ahttps?:\/\// || content =~ /\.(png|jpg|jpeg|gif|bmp|svg)\z/i
103
- "![](#{content})"
104
- elsif content =~ /\A([^:]+):(.+)\z/
105
- interwiki_name = $1
106
- page_name = $2
107
- if @interwiki_map[interwiki_name]
108
- url = @interwiki_map[interwiki_name] + CGI.escape(page_name).gsub('+', '%20')
109
- "[#{content}](#{url})"
110
- else
111
- "[#{content}](#{content}.md)"
112
- end
113
- else
114
- "[#{content}](#{content}.md)"
115
- end
238
+ ((previous_depth + 1)...level).each do |missing_level|
239
+ converted << render_list_item(types, missing_level, '<!-- -->')
116
240
  end
117
241
 
118
- # URL自動リンク
119
- line.gsub!(/(?<![\(\[])(https?:\/\/[^\s<>]+)/, '<\1>')
242
+ item = escape_markdown_block_start(convert_inline(entry[:content]))
243
+ converted << render_list_item(types, level, item)
244
+ end
120
245
 
121
- # WikiName自動リンク
122
- line.gsub!(/(?<![\(\[\/])([A-Z][a-z0-9]+[A-Z][a-z0-9]+[A-Za-z0-9]*)/, '[\1](\1.md)')
246
+ converted.join("\n")
247
+ end
123
248
 
124
- # 強調
125
- line.gsub! /'''(.+)'''/, "**\\1**"
126
- line.gsub! /''(.+)''/, "*\\1*"
249
+ def render_list_item(types, level, item)
250
+ indent = types.first(level - 1).inject(0) do |width, ancestor_type|
251
+ width + (ancestor_type == :ordered ? 3 : 2)
252
+ end
253
+ marker = types[level - 1] == :ordered ? '1.' : '-'
254
+ "#{' ' * indent}#{marker} #{item}"
255
+ end
127
256
 
128
- # 取り消し
129
- line.gsub! /\=\=(.+)\=\=/, "~~\\1~~"
257
+ def render_html_list(entries)
258
+ rendered = @source_template.dup
259
+ types = []
260
+ level = 0
130
261
 
262
+ entries.each do |entry|
263
+ new_level = entry[:level]
264
+ type = entry[:type]
265
+ item = convert_inline(entry[:content], context: :html)
131
266
 
132
- # 定義リスト
133
- if line =~ /\A\:(.+)\:(.+)/
134
- unless @in_dl_block
135
- @outputs << "<dl>"
267
+ if new_level > level
268
+ (new_level - level).times do
269
+ types << type
270
+ rendered << "<#{html_list_tag(type)}>\n<li>"
136
271
  end
137
- @outputs << "<dt>#{$1}</dt><dd>#{$2}</dd>"
138
- @in_dl_block = true
139
- next
272
+ rendered << item
273
+ elsif new_level < level
274
+ (level - new_level).times do
275
+ rendered << "</li>\n</#{html_list_tag(types.pop)}>"
276
+ end
277
+ rendered << "</li>\n<li>#{item}"
278
+ elsif type == types.last
279
+ rendered << "</li>\n<li>#{item}"
280
+ else
281
+ rendered << "</li>\n</#{html_list_tag(types.pop)}>\n"
282
+ rendered << "<#{html_list_tag(type)}>\n<li>#{item}"
283
+ types << type
140
284
  end
141
285
 
142
- if @in_dl_block
143
- if line !=~ /\A\:.+\:.+/
144
- @outputs << "</dl>"
145
- @in_dl_block = false
146
- end
286
+ level = new_level
287
+ end
288
+
289
+ level.times do
290
+ rendered << "</li>\n</#{html_list_tag(types.pop)}>"
291
+ end
292
+ rendered
293
+ end
294
+
295
+ def html_list_tag(type)
296
+ type == :unordered ? 'ul' : 'ol'
297
+ end
298
+
299
+ def convert_blockquote(lines, index, recursion_depth)
300
+ quoted_lines = []
301
+
302
+ while index < lines.length
303
+ line = lines[index]
304
+
305
+ if %r{\A//} =~ line
306
+ index += 1
307
+ elsif /\A""[ \t]?/ =~ line
308
+ quoted_lines << line
309
+ index += 1
310
+ else
311
+ break
147
312
  end
313
+ end
314
+
315
+ depths = quoted_lines.map { |line| blockquote_depth(line) }
316
+ if depths.uniq.length > MAX_BLOCKQUOTE_RECURSION
317
+ raise ArgumentError, 'blockquote nesting structure is too deep'
318
+ end
319
+
320
+ common_depth = depths.min
321
+ inner_lines = quoted_lines.map do |line|
322
+ strip_blockquote_prefix(line, common_depth)
323
+ end
324
+ if recursion_depth >= MAX_BLOCKQUOTE_RECURSION &&
325
+ inner_lines.any? { |line| /\A""[ \t]?/ =~ line }
326
+ raise ArgumentError, 'blockquote nesting structure is too deep'
327
+ end
328
+
329
+ inner = convert_lines(
330
+ inner_lines,
331
+ blockquote_recursion: recursion_depth + 1
332
+ )
333
+ prefix = '> ' * common_depth
334
+ quoted = inner.split("\n", -1).map do |quoted_line|
335
+ quoted_line.empty? ? prefix.rstrip : "#{prefix}#{quoted_line}"
336
+ end.join("\n")
337
+ [quoted, index]
338
+ end
339
+
340
+ def blockquote_depth(line)
341
+ depth = 0
342
+ offset = 0
343
+ while line[offset, 2] == '""'
344
+ offset += 2
345
+ offset += 1 if line[offset, 1] == ' ' || line[offset, 1] == "\t"
346
+ depth += 1
347
+ end
348
+ depth
349
+ end
350
+
351
+ def strip_blockquote_prefix(line, depth)
352
+ offset = 0
353
+ depth.times do
354
+ offset += 2
355
+ offset += 1 if line[offset, 1] == ' ' || line[offset, 1] == "\t"
356
+ end
357
+ line[offset..-1].to_s
358
+ end
148
359
 
149
- # テーブル
150
- if line =~ /\A\|\|/
151
- @in_table_block = true
152
- @table_contents << line
360
+ def convert_block_pre(lines, index, info)
361
+ index += 1
362
+ contents = []
363
+
364
+ while index < lines.length && lines[index] !~ /\A>>>/
365
+ contents << restore_plugins(lines[index])
366
+ index += 1
367
+ end
368
+ index += 1 if index < lines.length
369
+
370
+ [fenced_code(contents, info), index]
371
+ end
372
+
373
+ def convert_indented_pre(lines, index)
374
+ contents = []
375
+
376
+ while index < lines.length && /\A[ \t]/ =~ lines[index]
377
+ content = lines[index].sub(/\A[ \t]/, '').sub(/[ \t]+\z/, '')
378
+ contents << restore_plugins(content)
379
+ index += 1
380
+ end
381
+
382
+ [fenced_code(contents), index]
383
+ end
384
+
385
+ def fenced_code(lines, info = nil)
386
+ longest_run = lines.join("\n").scan(/`+/).map(&:length).max || 0
387
+ fence = '`' * [3, longest_run + 1].max
388
+ opening = info.to_s.empty? ? fence : "#{fence}#{info}"
389
+ ([opening] + lines + [fence]).join("\n")
390
+ end
391
+
392
+ def convert_definition_list(lines, index)
393
+ items = []
394
+
395
+ while index < lines.length
396
+ line = lines[index]
397
+
398
+ if %r{\A//} =~ line
399
+ index += 1
153
400
  next
154
401
  end
402
+ break unless /\A:/ =~ line
403
+
404
+ term, description = split_definition(line[1..-1])
405
+ term = convert_inline(term, context: :html)
406
+ description = convert_inline(description, context: :html)
407
+
408
+ if description.empty?
409
+ items << "<dt>#{term}</dt>"
410
+ elsif term.empty?
411
+ items << "<dd>#{description}</dd>"
412
+ else
413
+ items << "<dt>#{term}</dt><dd>#{description}</dd>"
414
+ end
415
+ index += 1
416
+ end
417
+
418
+ [(["<dl>"] + items + ["</dl>"]).join("\n"), index]
419
+ end
155
420
 
156
- if @in_table_block
157
- @outputs << make_table(@table_contents)
158
- @in_table_block = false
159
- @table_contents = []
421
+ def split_definition(line)
422
+ bracket_depth = 0
423
+ index = 0
424
+
425
+ while index < line.length
426
+ if line[index, 2] == '[['
427
+ bracket_depth += 1
428
+ index += 2
429
+ elsif line[index, 2] == ']]' && bracket_depth > 0
430
+ bracket_depth -= 1
431
+ index += 2
432
+ elsif line[index] == ':' && bracket_depth.zero?
433
+ return [line[0...index], line[(index + 1)..-1].to_s]
434
+ else
435
+ index += 1
160
436
  end
437
+ end
438
+
439
+ [line, '']
440
+ end
161
441
 
162
- @outputs << line
442
+ def collect_block(lines, index, pattern)
443
+ collected = []
444
+
445
+ while index < lines.length
446
+ if %r{\A//} =~ lines[index]
447
+ index += 1
448
+ elsif pattern =~ lines[index]
449
+ collected << lines[index]
450
+ index += 1
451
+ else
452
+ break
453
+ end
163
454
  end
164
455
 
165
- # ensure
166
- if @in_table_block
167
- @outputs << make_table(@table_contents)
168
- @in_table_block = false
169
- @table_contents = []
456
+ [collected, index]
457
+ end
458
+
459
+ def render_table(table_lines)
460
+ rows = parse_table_rows(table_lines)
461
+ render_html_table(rows)
462
+ end
463
+
464
+ def parse_table_rows(table_lines)
465
+ table_lines.map do |line|
466
+ body = line.sub(/\A\|\|/, '')
467
+ columns = body.split('||', -1)
468
+ columns.pop if columns.last == ''
469
+
470
+ columns.map do |column|
471
+ header = column.start_with?('!')
472
+ column = column[1..-1].to_s if header
473
+ span = column[/\A[\^>]*/].to_s
474
+ column = column[span.length..-1].to_s
475
+ rowspan_count = span.count('^')
476
+ colspan_count = span.count('>')
477
+
478
+ {
479
+ content: column,
480
+ header: header,
481
+ rowspan: rowspan_count.zero? ? nil : rowspan_count + 1,
482
+ colspan: colspan_count.zero? ? nil : colspan_count + 1
483
+ }
484
+ end
170
485
  end
486
+ end
171
487
 
172
- # ensure
173
- if @in_preformatted_block
174
- @outputs << "```"
488
+ def render_html_table(rows)
489
+ rendered_rows = rows.map do |row|
490
+ cells = row.map do |cell|
491
+ tag = cell[:header] ? 'th' : 'td'
492
+ attributes = []
493
+ attributes << %(rowspan="#{cell[:rowspan]}") if cell[:rowspan]
494
+ attributes << %(colspan="#{cell[:colspan]}") if cell[:colspan]
495
+ attribute_text = attributes.empty? ? '' : " #{attributes.join(' ')}"
496
+ content = convert_inline(cell[:content], context: :html)
497
+ "<#{tag}#{attribute_text}>#{content}</#{tag}>"
498
+ end.join
499
+ "<tr>#{cells}</tr>"
175
500
  end
176
501
 
177
- # ensure
178
- if @in_dl_block
179
- @outputs << "</dl>"
180
- @in_dl_block = false
502
+ (["<table>"] + rendered_rows + ["</table>"]).join("\n")
503
+ end
504
+
505
+ def convert_inline(text, context: :markdown)
506
+ remaining = text.to_s
507
+ result = remaining[0, 0]
508
+
509
+ while (match = INLINE_RE.match(remaining))
510
+ result << escape_text(match.pre_match, context)
511
+ token = match[0]
512
+
513
+ result << if plugin_token?(token)
514
+ convert_plugin_token(token, context)
515
+ elsif token.start_with?('[[')
516
+ convert_bracket_link(token[2...-2], context)
517
+ elsif /\A#{URI_RE}\z/ =~ token
518
+ convert_uri(token, context)
519
+ elsif /\A#{MODIFIER_RE}\z/ =~ token
520
+ convert_modifier(token, context)
521
+ else
522
+ convert_wiki_name(token, context)
523
+ end
524
+ remaining = match.post_match
181
525
  end
182
526
 
527
+ result << escape_text(remaining, context)
528
+ result
529
+ end
530
+
531
+ def convert_modifier(token, context)
532
+ marker_length = token.start_with?("'''") ? 3 : 2
533
+ marker = token[0, marker_length]
534
+ content = token[marker_length...-marker_length]
535
+
536
+ if marker == '``'
537
+ literal = restore_or_remove_plugins(content)
538
+ return literal if literal.empty?
539
+
540
+ if INLINE_RE =~ content
541
+ converted = convert_inline(content, context: :html)
542
+ return converted if converted.empty?
543
+ return "<code>#{converted}</code>"
544
+ end
545
+
546
+ return context == :html ? "<code>#{html_escape(literal)}</code>" : code_span(literal)
547
+ end
183
548
 
184
- @outputs.join("\n")
549
+ converted = convert_inline(content, context: context)
550
+ return converted if converted.empty?
551
+
552
+ tag = { "'''" => 'strong', "''" => 'em', '==' => 'del' }.fetch(marker)
553
+ if context == :html
554
+ "<#{tag}>#{converted}</#{tag}>"
555
+ elsif converted =~ /\A\s|\s\z/
556
+ html_content = convert_inline(content, context: :html)
557
+ return html_content if html_content.empty?
558
+ "<#{tag}>#{html_content}</#{tag}>"
559
+ else
560
+ markdown_marker = { "'''" => '**', "''" => '*', '==' => '~~' }.fetch(marker)
561
+ "#{markdown_marker}#{converted}#{markdown_marker}"
562
+ end
185
563
  end
186
564
 
187
- # tableから連結作用素に対応したmatrixを作る
188
- # input:lineごとに分割されたcontents
189
- # output:matrixと最長列数
190
- def make_matrix(contents)
191
- t_matrix = []
192
- contents.each do |line|
193
- row = line.split('||')
194
- row.shift
195
- t_matrix << row
565
+ def code_span(content)
566
+ longest_run = content.scan(/`+/).map(&:length).max || 0
567
+ delimiter = '`' * [1, longest_run + 1].max
568
+ padded_edge = content.start_with?('`', ' ') || content.end_with?('`', ' ')
569
+ padding = padded_edge && content !~ /\A\s*\z/ ? ' ' : ''
570
+ "#{delimiter}#{padding}#{content}#{padding}#{delimiter}"
571
+ end
572
+
573
+ def convert_bracket_link(content, context)
574
+ separator = content.rindex('|')
575
+ if separator
576
+ label = content[0...separator]
577
+ target = content[(separator + 1)..-1]
578
+ labeled = true
579
+ else
580
+ label = content
581
+ target = content
582
+ labeled = false
196
583
  end
197
584
 
198
- # vertical joint row
199
- t_matrix.each_with_index do |line, i|
200
- line.each_with_index do |e, j|
201
- if e =~ /\^+/
202
- t_matrix[i][j] = Regexp.last_match.post_match
203
- Regexp.last_match.size.times do |k|
204
- t_matrix[i + k + 1] ||= []
205
- t_matrix[i + k + 1].insert(j, " ")
206
- end
207
- end
585
+ target = restore_or_remove_plugins(target)
586
+
587
+ if image?(target)
588
+ destination = resolve_target(target, image: true)
589
+ alt = labeled ? plain_label(label) : image_alt(target)
590
+ return image_markup(alt, destination, context)
591
+ end
592
+
593
+ destination = resolve_target(target)
594
+ rendered_label = convert_label(label, context)
595
+ link_markup(rendered_label, destination, context)
596
+ end
597
+
598
+ def convert_uri(uri, context)
599
+ destination = normalize_hiki_uri(uri)
600
+ if image?(uri)
601
+ image_markup(image_alt(destination), destination, context)
602
+ elsif context == :html
603
+ label = html_escape(uri)
604
+ link_markup(label, destination, context)
605
+ elsif external_uri?(destination)
606
+ "<#{escape_destination(destination)}>"
607
+ else
608
+ label = escape_text(uri, context)
609
+ link_markup(label, destination, context)
610
+ end
611
+ end
612
+
613
+ def convert_label(label, context)
614
+ remaining = label.to_s
615
+ result = remaining[0, 0]
616
+ label_re = Regexp.union(/\0\d+\0/, MODIFIER_RE)
617
+
618
+ while (match = label_re.match(remaining))
619
+ result << escape_text(match.pre_match, context)
620
+ token = match[0]
621
+ result << if plugin_token?(token)
622
+ convert_plugin_token(token, context)
623
+ else
624
+ convert_modifier(token, context)
625
+ end
626
+ remaining = match.post_match
627
+ end
628
+
629
+ result << escape_text(remaining, context)
630
+ result
631
+ end
632
+
633
+ def plain_label(label)
634
+ restored = restore_or_remove_plugins(label)
635
+ restored.gsub(/[\r\n]+/, ' ')
636
+ end
637
+
638
+ def convert_wiki_name(token, context)
639
+ if token.start_with?('^')
640
+ return escape_text(token[1..-1], context) if @use_wiki_name
641
+ return escape_text(token, context)
642
+ end
643
+ return escape_text(token, context) unless @use_wiki_name
644
+
645
+ destination = resolve_target(token)
646
+ label = escape_text(token, context)
647
+ link_markup(label, destination, context)
648
+ end
649
+
650
+ def link_markup(label, destination, context)
651
+ destination = sanitize_destination(destination)
652
+ if context == :html
653
+ %(<a href="#{html_escape_attribute(destination)}">#{label}</a>)
654
+ else
655
+ "[#{label}](#{escape_destination(destination)})"
656
+ end
657
+ end
658
+
659
+ def image_markup(alt, destination, context)
660
+ destination = sanitize_destination(destination)
661
+ if context == :html
662
+ %(<img src="#{html_escape_attribute(destination)}" alt="#{html_escape_attribute(alt)}">)
663
+ else
664
+ "![#{escape_image_alt(alt)}](#{escape_destination(destination)})"
665
+ end
666
+ end
667
+
668
+ def resolve_target(target, image: false)
669
+ hiki_relative_uri = /\A(?:https?|ftp|file):(?!\/\/)/ =~ target
670
+ normalized = normalize_hiki_uri(target)
671
+ return normalized if normalized.empty?
672
+ return normalized if hiki_relative_uri
673
+ return normalized if external_uri?(normalized)
674
+ return normalized if normalized.start_with?('/', './', '../', '#', '?')
675
+
676
+ if /\A([^:]+):(.+)\z/ =~ normalized
677
+ interwiki_name = Regexp.last_match(1)
678
+ page_name = Regexp.last_match(2)
679
+ template = interwiki_template(interwiki_name)
680
+ if template
681
+ encoded_page = CGI.escape(page_name).gsub('+', '%20')
682
+ template = template.to_s
683
+ return template.include?('$1') ? template.gsub('$1', encoded_page) : template + encoded_page
208
684
  end
685
+
686
+ return normalized if safe_uri_scheme?(interwiki_name)
687
+ suffix = image ? '' : '.md'
688
+ return "./#{percent_encode(normalized)}#{suffix}"
209
689
  end
210
690
 
211
- # horizontal joint column
212
- max_col = 0
213
- t_matrix.each_with_index do |line, i|
214
- n_col = line.size
215
- j_col = 0
216
- line.each do |e|
217
- if e =~ />+/
218
- t_matrix[i][j_col] = Regexp.last_match.post_match
219
- cs = Regexp.last_match.size
220
- cs.times do
221
- j_col += 1
222
- t_matrix[i][j_col] = ""
223
- end
224
- n_col += cs
225
- else
226
- t_matrix[i][j_col] = e
227
- j_col += 1
228
- end
691
+ return percent_encode(normalized) if image
692
+
693
+ page_and_query, fragment = normalized.split('#', 2)
694
+ page_name, query = page_and_query.split('?', 2)
695
+ extension = page_name.end_with?('.md') ? '' : '.md'
696
+ destination = "#{percent_encode(page_name)}#{extension}"
697
+ destination << "?#{percent_encode(query)}" if query
698
+ destination << "##{percent_encode(fragment)}" if fragment
699
+ destination
700
+ end
701
+
702
+ def normalize_hiki_uri(uri)
703
+ if /\A(?:https?|ftp|file):(?!\/\/)/ =~ uri
704
+ uri.sub(/\A\w+:/, '')
705
+ else
706
+ uri
707
+ end
708
+ end
709
+
710
+ def external_uri?(target)
711
+ /\A(?:https?|ftp|file|mailto):/ =~ target
712
+ end
713
+
714
+ def image?(target)
715
+ path = target.to_s.split(/[?#]/, 2).first.to_s
716
+ extension = path[/\.[^.\/]+\z/].to_s.downcase
717
+ IMAGE_EXTENSIONS.include?(extension)
718
+ end
719
+
720
+ def image_alt(target)
721
+ path = normalize_hiki_uri(target.to_s).split(/[?#]/, 2).first
722
+ path.split('/').last.to_s
723
+ end
724
+
725
+ def interwiki_template(name)
726
+ return @interwiki_map[name] if @interwiki_map.key?(name)
727
+
728
+ symbol = name.to_sym
729
+ @interwiki_map[symbol] if @interwiki_map.key?(symbol)
730
+ end
731
+
732
+ def safe_uri_scheme?(scheme)
733
+ uri_scheme?(scheme) && !UNSAFE_URI_SCHEMES.include?(scheme.downcase)
734
+ end
735
+
736
+ def uri_scheme?(scheme)
737
+ /\A[A-Za-z][A-Za-z0-9+.-]*\z/ =~ scheme
738
+ end
739
+
740
+ def sanitize_destination(destination)
741
+ value = destination.to_s
742
+ candidate = CGI.unescapeHTML(value).
743
+ gsub(/&colon;/i, ':').
744
+ gsub(/&(?:Tab|NewLine);/i, '').
745
+ gsub(/[\x00-\x20]/, '')
746
+ encoded = value.gsub(/[\x00-\x20]/) do |character|
747
+ format('%%%02X', character.ord)
748
+ end
749
+ if /\A([A-Za-z][A-Za-z0-9+.-]*):/ =~ candidate &&
750
+ UNSAFE_URI_SCHEMES.include?(Regexp.last_match(1).downcase)
751
+ "./#{encoded}"
752
+ else
753
+ encoded
754
+ end
755
+ end
756
+
757
+ def percent_encode(value)
758
+ value.to_s.gsub("\n", '%0A').gsub("\r", '%0D').gsub("\t", '%09').
759
+ gsub(' ', '%20').gsub('(', '%28').gsub(')', '%29').
760
+ gsub('<', '%3C').gsub('>', '%3E').gsub('"', '%22').
761
+ gsub('{', '%7B').gsub('}', '%7D')
762
+ end
763
+
764
+ def escape_destination(destination)
765
+ percent_encode(destination).gsub(']', '%5D')
766
+ end
767
+
768
+ def escape_image_alt(alt)
769
+ escape_text(alt.to_s, :markdown)
770
+ end
771
+
772
+ def escape_text(text, context)
773
+ return html_escape(text) if context == :html
774
+
775
+ specials = "\\`*_[]~|"
776
+ value = text.to_s
777
+ value.each_char.each_with_object(value[0, 0]) do |character, escaped|
778
+ case character
779
+ when '&'
780
+ escaped << '&amp;'
781
+ when '<'
782
+ escaped << '&lt;'
783
+ when '>'
784
+ escaped << '&gt;'
785
+ else
786
+ escaped << (specials.include?(character) ? "\\#{character}" : character)
229
787
  end
230
- max_col = n_col if n_col > max_col
231
788
  end
789
+ end
232
790
 
233
- [t_matrix, max_col]
791
+ def escape_markdown_block_start(converted)
792
+ case converted
793
+ when /\A( {0,3})([-+])(?:\s|\z)/
794
+ converted.sub(/\A( {0,3})([-+])/) do
795
+ "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}"
796
+ end
797
+ when /\A( {0,3})[#]{1,6}(?:\s|\z)/
798
+ converted.sub(/\A( {0,3})#/) { "#{Regexp.last_match(1)}\\#" }
799
+ when /\A( {0,3})>(?:\s|\z)/
800
+ converted.sub(/\A( {0,3})>/) { "#{Regexp.last_match(1)}\\>" }
801
+ when /\A( {0,3})\d+[.)](?:\s|\z)/
802
+ converted.sub(/\A( {0,3})(\d+)([.)])/) do
803
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2)}\\#{Regexp.last_match(3)}"
804
+ end
805
+ when /\A( {0,3})(?:-+|=+)[ \t]*\z/
806
+ converted.sub(/\A( {0,3})/) { "#{Regexp.last_match(1)}\\" }
807
+ when /\A {4}/
808
+ converted.sub(/\A /, '&#32;')
809
+ when /\A\t/
810
+ converted.sub(/\A\t/, '&#9;')
811
+ else
812
+ converted
813
+ end
234
814
  end
235
815
 
236
- # tableを整形する
237
- def make_table(table_contents)
238
- contents, max_col = make_matrix(table_contents)
816
+ def escape_markdown_heading_closer(title)
817
+ return title unless /(?:\A|[ \t])[#]+\z/ =~ title
818
+
819
+ title.sub(/[#]+\z/) { |hashes| "\\#{hashes}" }
820
+ end
239
821
 
240
- align_line = "|"
241
- max_col.times { align_line << ':----|' }
242
- align_line << "\n"
822
+ def html_escape(text)
823
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
824
+ end
243
825
 
244
- table = "\n"
245
- contents.each_with_index do |line, idx|
246
- row = "|"
247
- line.each do |e|
248
- row << "#{e}|"
826
+ def html_escape_attribute(text)
827
+ html_escape(text).gsub('"', '&quot;').gsub("'", '&#39;')
828
+ end
829
+
830
+ def strip_hiki_whitespace(text)
831
+ text.to_s.sub(/\A[ \t\r\n\v\f]+/, '').sub(/[ \t\r\n\v\f]+\z/, '')
832
+ end
833
+
834
+ def extract_plugins(source)
835
+ @plugins = []
836
+ result = source[0, 0]
837
+ offset = 0
838
+
839
+ while (opening = source.index('{{', offset))
840
+ result << source[offset...opening]
841
+ closing = find_plugin_end(source, opening + 2)
842
+ unless closing
843
+ result << source[opening..-1]
844
+ offset = source.length
845
+ break
249
846
  end
250
- table << row + "\n"
251
847
 
252
- # insert table alignment after 1st line
253
- if idx == 0
254
- table << align_line
848
+ @plugins << source[(opening + 2)...closing]
849
+ result << "\0#{@plugins.length - 1}\0"
850
+ offset = closing + 2
851
+ end
852
+
853
+ result << source[offset..-1].to_s if offset < source.length
854
+ result
855
+ end
856
+
857
+ def find_plugin_end(source, offset)
858
+ quote = nil
859
+ escaped = false
860
+ index = offset
861
+
862
+ while index < source.length - 1
863
+ character = source[index]
864
+
865
+ if quote
866
+ if escaped
867
+ escaped = false
868
+ elsif character == '\\'
869
+ escaped = true
870
+ elsif character == quote
871
+ quote = nil
872
+ end
873
+ elsif character == "'" || character == '"'
874
+ quote = character
875
+ elsif source[index, 2] == '}}'
876
+ return index
255
877
  end
878
+
879
+ index += 1
256
880
  end
257
881
 
258
- table
882
+ nil
883
+ end
884
+
885
+ def convert_plugin_token(token, context)
886
+ return '' unless @preserve_plugins
887
+
888
+ plugin = token.sub(PLUGIN_TOKEN_RE) { "{{#{@plugins[Regexp.last_match(1).to_i]}}}" }
889
+ escape_text(plugin, context).gsub("\n", '&#10;').gsub("\r", '&#13;')
890
+ end
891
+
892
+ def plugin_token?(token)
893
+ /\A\0\d+\0\z/ =~ token
894
+ end
895
+
896
+ def restore_plugins(text)
897
+ text.to_s.gsub(PLUGIN_TOKEN_RE) do
898
+ "{{#{@plugins[Regexp.last_match(1).to_i]}}}"
899
+ end
900
+ end
901
+
902
+ def restore_or_remove_plugins(text)
903
+ if @preserve_plugins
904
+ restore_plugins(text)
905
+ else
906
+ text.to_s.gsub(PLUGIN_TOKEN_RE, '')
907
+ end
908
+ end
909
+
910
+ def plugin_only_line?(line)
911
+ /\A[ \t]*(?:\0\d+\0[ \t]*)+\z/ =~ line
259
912
  end
260
913
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiki2md
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masanori Kado
@@ -81,14 +81,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
81
81
  requirements:
82
82
  - - ">="
83
83
  - !ruby/object:Gem::Version
84
- version: '0'
84
+ version: 2.2.0
85
85
  required_rubygems_version: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
88
88
  - !ruby/object:Gem::Version
89
89
  version: '0'
90
90
  requirements: []
91
- rubygems_version: 3.6.8
91
+ rubygems_version: 4.0.13
92
92
  specification_version: 4
93
93
  summary: Converter of Hiki to Markdown
94
94
  test_files: []