hiki2md 0.1.3 → 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.
data/lib/hiki2md.rb CHANGED
@@ -1,226 +1,913 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  require 'hiki2md/version'
3
+ require 'cgi'
4
+ require 'thread'
3
5
 
4
6
  class Hiki2md
5
- def convert(lines)
6
- @outputs = []
7
-
8
- @in_plugin_block = false
9
- @in_preformatted_block = false
10
- @in_multiline_preformatted_block = false
11
- @in_table_block = false
12
- @in_dl_block = false
13
- @table_contents = []
14
-
15
- lines.split(/\n/).each do |line|
16
- # プラグイン
17
- if @in_plugin_block
18
- if line =~ /}}\z/
19
- @in_plugin_block = false
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)
25
+ @interwiki_map = interwiki_map
26
+ @use_wiki_name = use_wiki_name
27
+ @preserve_plugins = preserve_plugins
28
+ @conversion_mutex = Mutex.new
29
+ end
30
+
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
51
+
52
+ matrix.each_with_index do |line, row_index|
53
+ line.each_with_index do |cell, column_index|
54
+ next unless cell =~ /\^+/
55
+
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, ' ')
20
60
  end
21
- next
22
61
  end
62
+ end
23
63
 
24
- if line =~ /\A{{/
25
- next if line =~ /\A{{.+}}\z/
26
- @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
27
80
  end
81
+ max_columns = column_count if column_count > max_columns
82
+ end
83
+
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
28
103
 
29
- # 整形済みテキスト(複数行)
30
- if @in_multiline_preformatted_block
31
- if line =~ /\A>>>/
32
- @in_multiline_preformatted_block = false
33
- @outputs << '```'
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
34
140
  next
35
141
  end
36
- @outputs << line
37
- next
142
+
143
+ output << convert_regular_line(line)
144
+ index += 1
145
+ end
146
+ end
147
+
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
38
176
  end
177
+ joined << chunk
178
+ end
179
+ end
39
180
 
40
- if line =~ /\A<<<\s*(.*)/
41
- @in_multiline_preformatted_block = true
42
- @outputs << "```#{$1}"
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
43
197
  next
44
198
  end
45
199
 
46
- # 整形済みテキスト
47
- if @in_preformatted_block
48
- if line =~ /\A[ \t]+/
49
- @outputs << line.strip
50
- next
51
- else
52
- @outputs << "```"
53
- @in_preformatted_block = false
200
+ match = /\A([*#]+)[ \t]?(.*)\z/.match(line)
201
+ break unless match
202
+
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
236
+ end
237
+
238
+ ((previous_depth + 1)...level).each do |missing_level|
239
+ converted << render_list_item(types, missing_level, '<!-- -->')
240
+ end
241
+
242
+ item = escape_markdown_block_start(convert_inline(entry[:content]))
243
+ converted << render_list_item(types, level, item)
244
+ end
245
+
246
+ converted.join("\n")
247
+ end
248
+
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
256
+
257
+ def render_html_list(entries)
258
+ rendered = @source_template.dup
259
+ types = []
260
+ level = 0
261
+
262
+ entries.each do |entry|
263
+ new_level = entry[:level]
264
+ type = entry[:type]
265
+ item = convert_inline(entry[:content], context: :html)
266
+
267
+ if new_level > level
268
+ (new_level - level).times do
269
+ types << type
270
+ rendered << "<#{html_list_tag(type)}>\n<li>"
271
+ end
272
+ rendered << item
273
+ elsif new_level < level
274
+ (level - new_level).times do
275
+ rendered << "</li>\n</#{html_list_tag(types.pop)}>"
54
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
55
284
  end
56
285
 
57
- if line =~ /\A[ \t]+/
58
- @in_preformatted_block = true
59
- @outputs << "```\n#{line.strip}"
60
- next
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
61
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
62
350
 
63
- # コメント削除
64
- next if line =~ %r|\A//.*\z|
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
65
359
 
66
- # 引用
67
- line.gsub! /\A""/, '>'
360
+ def convert_block_pre(lines, index, info)
361
+ index += 1
362
+ contents = []
68
363
 
69
- # リンク
70
- line.gsub! /\[{2}([^\[\]\|]+?)\|([^\[\]\|]+?)\]{2}/, "[\\1](\\2)"
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
71
369
 
72
- # 強調
73
- line.gsub! /'''(.+)'''/, "**\\1**"
74
- line.gsub! /''(.+)''/, "*\\1*"
370
+ [fenced_code(contents, info), index]
371
+ end
75
372
 
76
- # 取り消し
77
- line.gsub! /\=\=(.+)\=\=/, "~~\\1~~"
373
+ def convert_indented_pre(lines, index)
374
+ contents = []
78
375
 
79
- # 箇条書き
80
- line.gsub! /\A[*]{3} ?/, ' - '
81
- line.gsub! /\A[*]{2} ?/, ' - '
82
- line.gsub! /\A[*] ?/ , '- '
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
83
381
 
84
- line.gsub! /\A[#]{3} ?/ , ' 1. '
85
- line.gsub! /\A[#]{2} ?/ , ' 1. '
86
- line.gsub! /\A[#] ?/ , '1. '
382
+ [fenced_code(contents), index]
383
+ end
87
384
 
88
- # 定義リスト
89
- if line =~ /\A\:(.+)\:(.+)/
90
- unless @in_dl_block
91
- @outputs << "<dl>"
92
- end
93
- @outputs << "<dt>#{$1}</dt><dd>#{$2}</dd>"
94
- @in_dl_block = true
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
95
400
  next
96
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
97
417
 
98
- if @in_dl_block
99
- if line !=~ /\A\:.+\:.+/
100
- @outputs << "</dl>"
101
- @in_dl_block = false
102
- end
418
+ [(["<dl>"] + items + ["</dl>"]).join("\n"), index]
419
+ end
420
+
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
436
+ end
437
+ end
438
+
439
+ [line, '']
440
+ end
441
+
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
103
453
  end
454
+ end
104
455
 
105
- # 見出し
106
- line.gsub! /\A!{5} ?/ , '##### '
107
- line.gsub! /\A!{4} ?/ , '#### '
108
- line.gsub! /\A!{3} ?/ , '### '
109
- line.gsub! /\A!{2} ?/ , '## '
110
- line.gsub! /\A! ?/ , '# '
456
+ [collected, index]
457
+ end
111
458
 
112
- # 画像
113
- line.gsub! /\[{2}([^\[\]\|]+?)\]{2}/, "![](\\1)"
459
+ def render_table(table_lines)
460
+ rows = parse_table_rows(table_lines)
461
+ render_html_table(rows)
462
+ end
114
463
 
115
- # テーブル
116
- if line =~ /\A\|\|/
117
- @in_table_block = true
118
- @table_contents << line
119
- next
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
+ }
120
484
  end
485
+ end
486
+ end
487
+
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>"
500
+ end
501
+
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
525
+ end
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]
121
535
 
122
- if @in_table_block
123
- @outputs << make_table(@table_contents)
124
- @in_table_block = false
125
- @table_contents = []
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>"
126
544
  end
127
545
 
128
- @outputs << line
546
+ return context == :html ? "<code>#{html_escape(literal)}</code>" : code_span(literal)
129
547
  end
130
548
 
131
- # ensure
132
- if @in_table_block
133
- @outputs << make_table(@table_contents)
134
- @in_table_block = false
135
- @table_contents = []
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}"
136
562
  end
563
+ end
137
564
 
138
- # ensure
139
- if @in_preformatted_block
140
- @outputs << "```"
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
141
583
  end
142
584
 
143
- # ensure
144
- if @in_dl_block
145
- @outputs << "</dl>"
146
- @in_dl_block = false
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)
147
591
  end
148
592
 
593
+ destination = resolve_target(target)
594
+ rendered_label = convert_label(label, context)
595
+ link_markup(rendered_label, destination, context)
596
+ end
149
597
 
150
- @outputs.join("\n")
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
151
611
  end
152
612
 
153
- # tableから連結作用素に対応したmatrixを作る
154
- # input:lineごとに分割されたcontents
155
- # output:matrixと最長列数
156
- def make_matrix(contents)
157
- t_matrix = []
158
- contents.each do |line|
159
- row = line.split('||')
160
- row.shift
161
- t_matrix << row
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
162
627
  end
163
628
 
164
- # vertical joint row
165
- t_matrix.each_with_index do |line, i|
166
- line.each_with_index do |e, j|
167
- if e =~ /\^+/
168
- t_matrix[i][j] = Regexp.last_match.post_match
169
- Regexp.last_match.size.times do |k|
170
- t_matrix[i + k + 1] ||= []
171
- t_matrix[i + k + 1].insert(j, " ")
172
- end
173
- end
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
174
684
  end
685
+
686
+ return normalized if safe_uri_scheme?(interwiki_name)
687
+ suffix = image ? '' : '.md'
688
+ return "./#{percent_encode(normalized)}#{suffix}"
175
689
  end
176
690
 
177
- # horizontal joint column
178
- max_col = 0
179
- t_matrix.each_with_index do |line, i|
180
- n_col = line.size
181
- j_col = 0
182
- line.each do |e|
183
- if e =~ />+/
184
- t_matrix[i][j_col] = Regexp.last_match.post_match
185
- cs = Regexp.last_match.size
186
- cs.times do
187
- j_col += 1
188
- t_matrix[i][j_col] = ""
189
- end
190
- n_col += cs
191
- else
192
- t_matrix[i][j_col] = e
193
- j_col += 1
194
- 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)
195
787
  end
196
- max_col = n_col if n_col > max_col
197
788
  end
789
+ end
198
790
 
199
- [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
200
814
  end
201
815
 
202
- # tableを整形する
203
- def make_table(table_contents)
204
- 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
205
821
 
206
- align_line = "|"
207
- max_col.times { align_line << ':----|' }
208
- align_line << "\n"
822
+ def html_escape(text)
823
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
824
+ end
209
825
 
210
- table = "\n"
211
- contents.each_with_index do |line, idx|
212
- row = "|"
213
- line.each do |e|
214
- 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
215
846
  end
216
- table << row + "\n"
217
847
 
218
- # insert table alignment after 1st line
219
- if idx == 0
220
- 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
221
877
  end
878
+
879
+ index += 1
222
880
  end
223
881
 
224
- 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
225
912
  end
226
913
  end