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 +4 -4
- data/.travis.yml +4 -2
- data/Gemfile.lock +1 -1
- data/README.md +83 -2
- data/exe/hiki2md +16 -2
- data/hiki2md.gemspec +1 -0
- data/lib/hiki2md/version.rb +1 -1
- data/lib/hiki2md.rb +838 -185
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b4d6cfffb000735e6110b7f97177fed422bd998b1ea8d07672ee0965f0232f8
|
|
4
|
+
data.tar.gz: 92ac94a769691e6bd4a7662bb0b33adcc7b6036d720f5d7171b21c08511a6954
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc77e072352a1a301871154af34120ccf5a32bb0857019e92132a0867a972c7ff918f53915a062fc3317b1c702b1512dfdc25889f9f590c0b18f28c883c80820
|
|
7
|
+
data.tar.gz: 3c6df53740b623b39bb5f20e11d314a6f6de3f251cfced2e7f7bb596d98f2a67d4cf04573bb58f3179510ee80d1ac1f018489fc7bc6ed81cc9ae3d82084a66be
|
data/.travis.yml
CHANGED
data/Gemfile.lock
CHANGED
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
|
-
|
|
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(
|
|
31
|
-
|
|
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"
|
data/lib/hiki2md/version.rb
CHANGED
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
|
-
|
|
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(
|
|
11
|
-
@
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
@outputs << "```#{$1}"
|
|
48
|
-
next
|
|
143
|
+
output << convert_regular_line(line)
|
|
144
|
+
index += 1
|
|
49
145
|
end
|
|
146
|
+
end
|
|
50
147
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
200
|
+
match = /\A([*#]+)[ \t]?(.*)\z/.match(line)
|
|
201
|
+
break unless match
|
|
70
202
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
""
|
|
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
|
-
|
|
119
|
-
|
|
242
|
+
item = escape_markdown_block_start(convert_inline(entry[:content]))
|
|
243
|
+
converted << render_list_item(types, level, item)
|
|
244
|
+
end
|
|
120
245
|
|
|
121
|
-
|
|
122
|
-
|
|
246
|
+
converted.join("\n")
|
|
247
|
+
end
|
|
123
248
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
"})"
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(/:/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 << '&'
|
|
781
|
+
when '<'
|
|
782
|
+
escaped << '<'
|
|
783
|
+
when '>'
|
|
784
|
+
escaped << '>'
|
|
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
|
-
|
|
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 /, ' ')
|
|
809
|
+
when /\A\t/
|
|
810
|
+
converted.sub(/\A\t/, '	')
|
|
811
|
+
else
|
|
812
|
+
converted
|
|
813
|
+
end
|
|
234
814
|
end
|
|
235
815
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
822
|
+
def html_escape(text)
|
|
823
|
+
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
824
|
+
end
|
|
243
825
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
826
|
+
def html_escape_attribute(text)
|
|
827
|
+
html_escape(text).gsub('"', '"').gsub("'", ''')
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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", ' ').gsub("\r", ' ')
|
|
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.
|
|
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:
|
|
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:
|
|
91
|
+
rubygems_version: 4.0.13
|
|
92
92
|
specification_version: 4
|
|
93
93
|
summary: Converter of Hiki to Markdown
|
|
94
94
|
test_files: []
|