markdownr 0.6.17 → 0.7.1
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/bin/markdownr +8 -13
- data/lib/markdown_server/app.rb +38 -1025
- data/lib/markdown_server/helpers/admin_helpers.rb +42 -0
- data/lib/markdown_server/helpers/fetch_helpers.rb +178 -0
- data/lib/markdown_server/helpers/formatting_helpers.rb +78 -0
- data/lib/markdown_server/helpers/markdown_helpers.rb +216 -0
- data/lib/markdown_server/helpers/path_helpers.rb +40 -0
- data/lib/markdown_server/helpers/search_helpers.rb +157 -0
- data/lib/markdown_server/plugin.rb +14 -2
- data/lib/markdown_server/plugins/bible_citations/helpers.rb +365 -0
- data/lib/markdown_server/plugins/bible_citations/plugin.rb +27 -3
- data/lib/markdown_server/version.rb +1 -1
- metadata +8 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Helpers
|
|
3
|
+
module AdminHelpers
|
|
4
|
+
def setup_config
|
|
5
|
+
@setup_config ||= begin
|
|
6
|
+
path = File.join(root_dir, ".setup.yml")
|
|
7
|
+
(File.exist?(path) && YAML.safe_load(File.read(path))) || {}
|
|
8
|
+
rescue StandardError
|
|
9
|
+
{}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def client_ip
|
|
14
|
+
if settings.behind_proxy
|
|
15
|
+
fwd = env["HTTP_X_FORWARDED_FOR"].to_s.split(",").map(&:strip).first
|
|
16
|
+
fwd && !fwd.empty? ? fwd : env["REMOTE_ADDR"]
|
|
17
|
+
else
|
|
18
|
+
env["REMOTE_ADDR"]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def admin?
|
|
23
|
+
return true if session[:admin]
|
|
24
|
+
|
|
25
|
+
adm = setup_config["admin"]
|
|
26
|
+
return false unless adm.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
return true if adm["ip"].to_s.strip == client_ip
|
|
29
|
+
|
|
30
|
+
if adm["user"] && adm["pw"]
|
|
31
|
+
auth = request.env["HTTP_AUTHORIZATION"].to_s
|
|
32
|
+
if auth.start_with?("Basic ")
|
|
33
|
+
user, pw = Base64.decode64(auth[6..]).split(":", 2)
|
|
34
|
+
return true if user == adm["user"].to_s && pw == adm["pw"].to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Helpers
|
|
3
|
+
module FetchHelpers
|
|
4
|
+
# CSS cache for external stylesheets (keyed by absolute URL)
|
|
5
|
+
@@css_cache = {}
|
|
6
|
+
CSS_TTL = 3600 # 1 hour
|
|
7
|
+
|
|
8
|
+
FETCH_MAX_BYTES = 512_000
|
|
9
|
+
FETCH_TIMEOUT = 5
|
|
10
|
+
|
|
11
|
+
# Tags kept as-is (attributes stripped)
|
|
12
|
+
ALLOWED_HTML = %w[p h1 h2 h3 h4 h5 h6 blockquote ul ol li
|
|
13
|
+
pre br hr strong b em i sup sub code
|
|
14
|
+
table tr td th].to_set
|
|
15
|
+
# Block containers — replaced with a newline (content kept)
|
|
16
|
+
BLOCK_HTML = %w[div section aside figure figcaption
|
|
17
|
+
thead tbody tfoot].to_set
|
|
18
|
+
# Elements removed completely, including their content
|
|
19
|
+
STRIP_FULL = %w[script style nav header footer form input
|
|
20
|
+
button select textarea svg iframe noscript].to_set
|
|
21
|
+
|
|
22
|
+
# ── HTTP Fetching ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def fetch_css(url_str)
|
|
25
|
+
cached = @@css_cache[url_str]
|
|
26
|
+
return cached[:body] if cached && (Time.now - cached[:at]) < CSS_TTL
|
|
27
|
+
|
|
28
|
+
uri = URI.parse(url_str)
|
|
29
|
+
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
30
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
|
+
http.use_ssl = (uri.scheme == "https")
|
|
32
|
+
http.open_timeout = FETCH_TIMEOUT
|
|
33
|
+
http.read_timeout = FETCH_TIMEOUT
|
|
34
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
35
|
+
req["Accept"] = "text/css"
|
|
36
|
+
resp = http.request(req)
|
|
37
|
+
body = resp.is_a?(Net::HTTPSuccess) ? resp.body.to_s.encode("utf-8", invalid: :replace, undef: :replace) : nil
|
|
38
|
+
@@css_cache[url_str] = { body: body, at: Time.now } if body
|
|
39
|
+
body
|
|
40
|
+
rescue
|
|
41
|
+
@@css_cache.dig(url_str, :body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_external_page(url_str)
|
|
45
|
+
uri = URI.parse(url_str)
|
|
46
|
+
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
47
|
+
fetch_follow_redirects(uri, 5)
|
|
48
|
+
rescue
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_follow_redirects(uri, limit)
|
|
53
|
+
return nil if limit <= 0
|
|
54
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
+
http.use_ssl = (uri.scheme == "https")
|
|
56
|
+
http.open_timeout = FETCH_TIMEOUT
|
|
57
|
+
http.read_timeout = FETCH_TIMEOUT
|
|
58
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
59
|
+
req["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
60
|
+
req["Accept"] = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8"
|
|
61
|
+
req["Accept-Language"] = "en-US,en;q=0.5"
|
|
62
|
+
resp = http.request(req)
|
|
63
|
+
case resp
|
|
64
|
+
when Net::HTTPSuccess
|
|
65
|
+
ct = resp["content-type"].to_s
|
|
66
|
+
return nil unless ct.match?(/html|text/i)
|
|
67
|
+
body = resp.body.to_s
|
|
68
|
+
body = body.b[0, FETCH_MAX_BYTES].force_encoding("utf-8")
|
|
69
|
+
body.encode("utf-8", invalid: :replace, undef: :replace, replace: "?")
|
|
70
|
+
when Net::HTTPRedirection
|
|
71
|
+
loc = resp["Location"].to_s
|
|
72
|
+
new_uri = (URI.parse(loc) rescue nil)
|
|
73
|
+
return nil unless new_uri
|
|
74
|
+
new_uri = uri + new_uri unless new_uri.absolute?
|
|
75
|
+
return nil unless new_uri.is_a?(URI::HTTP) || new_uri.is_a?(URI::HTTPS)
|
|
76
|
+
fetch_follow_redirects(new_uri, limit - 1)
|
|
77
|
+
end
|
|
78
|
+
rescue
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── HTML Extraction & Sanitization ────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def page_title(html)
|
|
85
|
+
html.match(/<title[^>]*>(.*?)<\/title>/im)&.then { |m|
|
|
86
|
+
m[1].gsub(/<[^>]+>/, "").gsub(/&/i, "&").gsub(/</i, "<")
|
|
87
|
+
.gsub(/>/i, ">").gsub(/"/i, '"').gsub(/&#?\w+;/, "").strip
|
|
88
|
+
} || ""
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def page_html(raw, base_url = nil)
|
|
92
|
+
w = raw.dup
|
|
93
|
+
STRIP_FULL.each { |t| w.gsub!(/<#{t}[^>]*>.*?<\/#{t}>/im, " ") }
|
|
94
|
+
w.gsub!(/<!--.*?-->/m, " ")
|
|
95
|
+
w.gsub!(/Bible\s+Gateway\s+Recommends[\s\S]*?View\s+more\s+titles/i, " ")
|
|
96
|
+
w.gsub!(/trusted\s+resources\s+beside\s+every\s+verse[\s\S]*?Your\s+Content/i, " ")
|
|
97
|
+
w.gsub!(/Log\s+In\s*\/\s*Sign\s+Up[\s\S]*?Your\s+Content/i, " ")
|
|
98
|
+
|
|
99
|
+
content = w.match(/<article[^>]*>(.*?)<\/article>/im)&.[](1) ||
|
|
100
|
+
w.match(/<main[^>]*>(.*?)<\/main>/im)&.[](1) ||
|
|
101
|
+
w.match(/<body[^>]*>(.*?)<\/body>/im)&.[](1) ||
|
|
102
|
+
w
|
|
103
|
+
|
|
104
|
+
out = sanitize_html_tags(content, base_url)
|
|
105
|
+
out.sub!(/\A[\s\S]*?(?=<h[1-6]>)/i, "")
|
|
106
|
+
out = decode_html_entities(out)
|
|
107
|
+
out.gsub!(/(<a[^>]*>Read\s+full\s+chapter<\/a>)[\s\S]*?(?=©|Copyright\b)/i, "\\1\n")
|
|
108
|
+
|
|
109
|
+
out.length > 10_000 ? out[0, 10_000] : out
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ── Asset Injection ───────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def inject_assets_for_html_path?(_relative_path)
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def inject_markdownr_assets(html_content)
|
|
119
|
+
settings.plugins.each { |p| html_content = p.transform_html(html_content, self) }
|
|
120
|
+
|
|
121
|
+
popup_config_script = "<script>var __popupConfig = {" \
|
|
122
|
+
"localMd:#{settings.popup_local_md}," \
|
|
123
|
+
"localHtml:#{settings.popup_local_html}," \
|
|
124
|
+
"external:#{settings.popup_external}," \
|
|
125
|
+
"externalDomains:#{settings.popup_external_domains.to_json}" \
|
|
126
|
+
"};</script>\n"
|
|
127
|
+
assets = popup_config_script + File.read(File.join(settings.views, "popup_assets.erb"))
|
|
128
|
+
inserted = false
|
|
129
|
+
result = html_content.sub(/<\/(body|html)>/i) { inserted = true; "#{assets}</#{$1}>" }
|
|
130
|
+
inserted ? result : html_content + assets
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def decode_html_entities(text)
|
|
136
|
+
text
|
|
137
|
+
.gsub(/ /i, " ").gsub(/&/i, "&").gsub(/</i, "<").gsub(/>/i, ">")
|
|
138
|
+
.gsub(/"/i, '"').gsub(/'/i, "'")
|
|
139
|
+
.gsub(/—/i, "—").gsub(/–/i, "–").gsub(/…/i, "…")
|
|
140
|
+
.gsub(/&#(\d+);/) { [$1.to_i].pack("U") rescue " " }
|
|
141
|
+
.gsub(/&#x([\da-f]+);/i) { [$1.to_i(16)].pack("U") rescue " " }
|
|
142
|
+
.gsub(/&\w+;/, " ")
|
|
143
|
+
.gsub(/[ \t]+/, " ")
|
|
144
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
145
|
+
.gsub(/<(\w+)>\s*<\/\1>/, "")
|
|
146
|
+
.strip
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sanitize_html_tags(content, base_url = nil)
|
|
150
|
+
content.gsub(/<(\/?)(\w+)([^>]*)>/) do
|
|
151
|
+
slash, tag, attrs = $1, $2.downcase, $3
|
|
152
|
+
if ALLOWED_HTML.include?(tag)
|
|
153
|
+
"<#{slash}#{tag}>"
|
|
154
|
+
elsif tag == "a"
|
|
155
|
+
if slash.empty?
|
|
156
|
+
m = attrs.match(/href\s*=\s*["']([^"']*)["']/i)
|
|
157
|
+
if m && !m[1].match?(/\Ajavascript:/i)
|
|
158
|
+
href = m[1].gsub(/&/i, "&")
|
|
159
|
+
if base_url && href !~ /\Ahttps?:\/\//i && !href.start_with?("#")
|
|
160
|
+
href = (URI.join(base_url, href).to_s rescue href)
|
|
161
|
+
end
|
|
162
|
+
%(<a href="#{h(href)}" target="_blank" rel="noopener">)
|
|
163
|
+
else
|
|
164
|
+
""
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
"</a>"
|
|
168
|
+
end
|
|
169
|
+
elsif BLOCK_HTML.include?(tag)
|
|
170
|
+
"\n"
|
|
171
|
+
else
|
|
172
|
+
""
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Helpers
|
|
3
|
+
module FormattingHelpers
|
|
4
|
+
def format_size(bytes)
|
|
5
|
+
if bytes < 1024
|
|
6
|
+
"#{bytes} B"
|
|
7
|
+
elsif bytes < 1024 * 1024
|
|
8
|
+
"%.1f KB" % (bytes / 1024.0)
|
|
9
|
+
else
|
|
10
|
+
"%.1f MB" % (bytes / (1024.0 * 1024))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format_date(time)
|
|
15
|
+
time.strftime("%Y-%m-%d %H:%M")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def icon_for(entry_name, is_dir)
|
|
19
|
+
if is_dir
|
|
20
|
+
"\u{1F4C1}"
|
|
21
|
+
else
|
|
22
|
+
ext = File.extname(entry_name).downcase
|
|
23
|
+
case ext
|
|
24
|
+
when ".md" then "\u{1F4DD}"
|
|
25
|
+
when ".pdf" then "\u{1F4D5}"
|
|
26
|
+
when ".json" then "\u{1F4CB}"
|
|
27
|
+
when ".py" then "\u{1F40D}"
|
|
28
|
+
when ".rb" then "\u{1F48E}"
|
|
29
|
+
when ".csv" then "\u{1F4CA}"
|
|
30
|
+
when ".epub" then "\u{1F4D6}"
|
|
31
|
+
else "\u{1F4C4}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def breadcrumbs(path)
|
|
37
|
+
parts = path.split("/").reject(&:empty?)
|
|
38
|
+
crumbs = [{ name: "home", href: "/browse/" }]
|
|
39
|
+
parts.each_with_index do |part, i|
|
|
40
|
+
href = "/browse/" + parts[0..i].map { |p| encode_path_component(p) }.join("/") + "/"
|
|
41
|
+
crumbs << { name: part, href: href }
|
|
42
|
+
end
|
|
43
|
+
crumbs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dir_title
|
|
47
|
+
return settings.custom_title if settings.respond_to?(:custom_title) && settings.custom_title
|
|
48
|
+
File.basename(root_dir).gsub(/[-_]/, " ").gsub(/\b\w/, &:upcase)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inline_directory_html(dir_path, relative_dir)
|
|
52
|
+
entries = Dir.entries(dir_path).reject { |e| e.start_with?(".") || EXCLUDED.include?(e) }
|
|
53
|
+
items = entries.map do |name|
|
|
54
|
+
stat = File.stat(File.join(dir_path, name)) rescue next
|
|
55
|
+
is_dir = stat.directory?
|
|
56
|
+
href = "/browse/" + (relative_dir.empty? ? "" : relative_dir + "/") +
|
|
57
|
+
encode_path_component(name) + (is_dir ? "/" : "")
|
|
58
|
+
{ name: name, is_dir: is_dir, href: href }
|
|
59
|
+
end.compact.sort_by { |i| [i[:is_dir] ? 0 : 1, i[:name].downcase] }
|
|
60
|
+
|
|
61
|
+
rows = items.map do |i|
|
|
62
|
+
%(<li><a href="#{h(i[:href])}"><span class="icon">#{icon_for(i[:name], i[:is_dir])}</span> ) +
|
|
63
|
+
%(#{h(i[:name])}#{i[:is_dir] ? "/" : ""}</a></li>)
|
|
64
|
+
end.join
|
|
65
|
+
%(<ul class="dir-listing">#{rows}</ul>)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def search_form_path(relative_path)
|
|
69
|
+
"/search/" + relative_path.split("/").map { |p| encode_path_component(p) }.join("/")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parent_dir_path(relative_path)
|
|
73
|
+
parts = relative_path.split("/")
|
|
74
|
+
parts.length > 1 ? parts[0..-2].join("/") : ""
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Helpers
|
|
3
|
+
module MarkdownHelpers
|
|
4
|
+
def parse_frontmatter(content)
|
|
5
|
+
if content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
6
|
+
begin
|
|
7
|
+
meta = YAML.safe_load($1, permitted_classes: [Date, Time])
|
|
8
|
+
body = $2
|
|
9
|
+
[meta, body]
|
|
10
|
+
rescue => e
|
|
11
|
+
[nil, content]
|
|
12
|
+
end
|
|
13
|
+
else
|
|
14
|
+
[nil, content]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render_markdown(text)
|
|
19
|
+
# Convert mermaid code fences to raw HTML divs before Kramdown so Rouge
|
|
20
|
+
# never touches them and the content is preserved exactly for Mermaid.js.
|
|
21
|
+
text = text.gsub(/^```mermaid[ \t]*\r?\n([\s\S]*?)^```[ \t]*(\r?\n|\z)/m) do
|
|
22
|
+
"<div class=\"mermaid\">\n#{h($1.rstrip)}\n</div>\n\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run plugin markdown transformations (e.g. Bible citation auto-linking)
|
|
26
|
+
settings.plugins.each { |p| text = p.transform_markdown(text) }
|
|
27
|
+
|
|
28
|
+
# Process wiki links BEFORE Kramdown so that | isn't consumed as
|
|
29
|
+
# a GFM table delimiter.
|
|
30
|
+
text = text.gsub(/\[\[([^\]]+)\]\]/) do
|
|
31
|
+
raw = $1
|
|
32
|
+
if raw.include?("|")
|
|
33
|
+
target, display = raw.split("|", 2)
|
|
34
|
+
else
|
|
35
|
+
target = raw
|
|
36
|
+
display = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if target.start_with?("#")
|
|
40
|
+
heading_text = target[1..]
|
|
41
|
+
anchor = heading_text.downcase.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
|
|
42
|
+
label = display || heading_text
|
|
43
|
+
%(<a class="wiki-link" href="##{h(anchor)}">#{h(label)}</a>)
|
|
44
|
+
else
|
|
45
|
+
file_part, anchor_part = target.split("#", 2)
|
|
46
|
+
anchor_suffix = anchor_part ? "##{anchor_part.downcase.gsub(/\s+/, '-').gsub(/[^\w-]/, '')}" : ""
|
|
47
|
+
resolved = resolve_wiki_link(file_part)
|
|
48
|
+
label = display || target
|
|
49
|
+
if resolved
|
|
50
|
+
%(<a class="wiki-link" href="/browse/#{encode_path_component(resolved).gsub('%2F', '/')}#{anchor_suffix}">#{h(label)}</a>)
|
|
51
|
+
else
|
|
52
|
+
%(<span class="wiki-link broken">#{h(label)}</span>)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
html = Kramdown::Document.new(
|
|
58
|
+
text,
|
|
59
|
+
input: "GFM",
|
|
60
|
+
syntax_highlighter: "rouge",
|
|
61
|
+
syntax_highlighter_opts: { default_lang: "text" },
|
|
62
|
+
hard_wrap: settings.hard_wrap
|
|
63
|
+
).to_html
|
|
64
|
+
|
|
65
|
+
# Restore non-numeric footnote labels: Kramdown converts all footnote
|
|
66
|
+
# references to sequential numbers, but we want [^abc] to display "abc".
|
|
67
|
+
html = html.gsub(%r{<sup id="fnref:([^"]+)"[^>]*>\s*<a href="#fn:\1"[^>]*>\d+</a>\s*</sup>}m) do
|
|
68
|
+
full_match = $&
|
|
69
|
+
name = $1
|
|
70
|
+
if name =~ /\A\d+\z/
|
|
71
|
+
full_match
|
|
72
|
+
else
|
|
73
|
+
%(<sup id="fnref:#{name}" role="doc-noteref"><a href="#fn:#{name}" class="footnote" rel="footnote">#{h(name)}</a></sup>)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
77
|
+
full_match = $&
|
|
78
|
+
name = $1
|
|
79
|
+
if name =~ /\A\d+\z/
|
|
80
|
+
full_match
|
|
81
|
+
else
|
|
82
|
+
%(<li id="fn:#{name}"><p><strong>#{h(name)}:</strong> )
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def resolve_wiki_link(name)
|
|
88
|
+
filename = "#{name}.md"
|
|
89
|
+
base = File.realpath(root_dir)
|
|
90
|
+
# On case-sensitive filesystems (Linux/Docker), FNM_CASEFOLD doesn't help
|
|
91
|
+
# with directory listings. Try exact filename first, then lowercased variant.
|
|
92
|
+
candidates = [filename]
|
|
93
|
+
candidates << filename.downcase if filename != filename.downcase
|
|
94
|
+
|
|
95
|
+
# Check the current file's directory first (exact case, then case-insensitive)
|
|
96
|
+
if @current_wiki_dir
|
|
97
|
+
local_exact = nil
|
|
98
|
+
local_ci = nil
|
|
99
|
+
candidates.each do |fn|
|
|
100
|
+
Dir.glob(File.join(@current_wiki_dir, fn)).each do |path|
|
|
101
|
+
real = File.realpath(path) rescue next
|
|
102
|
+
next unless real.start_with?(base)
|
|
103
|
+
relative = real.sub("#{base}/", "")
|
|
104
|
+
first_segment = relative.split("/").first
|
|
105
|
+
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
106
|
+
if File.basename(real) == filename
|
|
107
|
+
local_exact = relative
|
|
108
|
+
break
|
|
109
|
+
else
|
|
110
|
+
local_ci ||= relative
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
break if local_exact
|
|
114
|
+
end
|
|
115
|
+
return local_exact if local_exact
|
|
116
|
+
return local_ci if local_ci
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fall back to global recursive search
|
|
120
|
+
exact_match = nil
|
|
121
|
+
ci_match = nil
|
|
122
|
+
candidates.each do |fn|
|
|
123
|
+
Dir.glob(File.join(base, "**", fn)).each do |path|
|
|
124
|
+
real = File.realpath(path) rescue next
|
|
125
|
+
next unless real.start_with?(base)
|
|
126
|
+
relative = real.sub("#{base}/", "")
|
|
127
|
+
first_segment = relative.split("/").first
|
|
128
|
+
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
129
|
+
if File.basename(real) == filename
|
|
130
|
+
exact_match ||= relative
|
|
131
|
+
else
|
|
132
|
+
ci_match ||= relative
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
break if exact_match
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
exact_match || ci_match
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def render_inline_wiki_links(str)
|
|
142
|
+
result = ""
|
|
143
|
+
last_end = 0
|
|
144
|
+
str.scan(/\[\[([^\]]+)\]\]/) do |match|
|
|
145
|
+
raw = match[0]
|
|
146
|
+
m_start = $~.begin(0)
|
|
147
|
+
m_end = $~.end(0)
|
|
148
|
+
result += h(str[last_end...m_start])
|
|
149
|
+
target, display = raw.include?("|") ? raw.split("|", 2) : [raw, nil]
|
|
150
|
+
label = display || target
|
|
151
|
+
if target.start_with?("#")
|
|
152
|
+
anchor = target[1..].downcase.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
|
|
153
|
+
result += %(<a class="wiki-link" href="##{h(anchor)}">#{h(label)}</a>)
|
|
154
|
+
else
|
|
155
|
+
file_part, anchor_part = target.split("#", 2)
|
|
156
|
+
anchor_suffix = anchor_part ? "##{anchor_part.downcase.gsub(/\s+/, '-').gsub(/[^\w-]/, '')}" : ""
|
|
157
|
+
resolved = resolve_wiki_link(file_part)
|
|
158
|
+
if resolved
|
|
159
|
+
result += %(<a class="wiki-link" href="/browse/#{encode_path_component(resolved).gsub('%2F', '/')}#{anchor_suffix}">#{h(label)}</a>)
|
|
160
|
+
else
|
|
161
|
+
result += %(<span class="wiki-link broken">#{h(label)}</span>)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
last_end = m_end
|
|
165
|
+
end
|
|
166
|
+
result += h(str[last_end..])
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def render_frontmatter_value(value)
|
|
171
|
+
case value
|
|
172
|
+
when Array
|
|
173
|
+
value.map { |v|
|
|
174
|
+
str = v.to_s
|
|
175
|
+
if str.include?("[[")
|
|
176
|
+
render_inline_wiki_links(str)
|
|
177
|
+
else
|
|
178
|
+
%(<span class="tag">#{h(str)}</span>)
|
|
179
|
+
end
|
|
180
|
+
}.join(" ")
|
|
181
|
+
when String
|
|
182
|
+
if value =~ /\Ahttps?:\/\//
|
|
183
|
+
%(<a href="#{h(value)}" target="_blank" rel="noopener">#{h(value)}</a>)
|
|
184
|
+
elsif value.include?("[[")
|
|
185
|
+
render_inline_wiki_links(value)
|
|
186
|
+
elsif value.length > 120
|
|
187
|
+
render_markdown(value)
|
|
188
|
+
else
|
|
189
|
+
h(value)
|
|
190
|
+
end
|
|
191
|
+
when Numeric, TrueClass, FalseClass
|
|
192
|
+
h(value.to_s)
|
|
193
|
+
when NilClass
|
|
194
|
+
%(<span class="empty">—</span>)
|
|
195
|
+
else
|
|
196
|
+
h(value.to_s)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def syntax_highlight(code, language)
|
|
201
|
+
formatter = Rouge::Formatters::HTML.new
|
|
202
|
+
lexer = Rouge::Lexer.find_fancy(language) || Rouge::Lexers::PlainText.new
|
|
203
|
+
formatter.format(lexer.lex(code))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def extract_toc(html)
|
|
207
|
+
headings = []
|
|
208
|
+
html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
|
|
209
|
+
clean_text = text.gsub(/<sup[^>]*id="fnref:[^"]*"[^>]*>.*?<\/sup>/i, "").gsub(/<[^>]+>/, "").strip
|
|
210
|
+
headings << { level: level.to_i, id: id, text: clean_text }
|
|
211
|
+
end
|
|
212
|
+
headings
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Helpers
|
|
3
|
+
module PathHelpers
|
|
4
|
+
def root_dir
|
|
5
|
+
settings.root_dir
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def h(text)
|
|
9
|
+
CGI.escapeHTML(text.to_s)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def encode_path_component(str)
|
|
13
|
+
URI.encode_www_form_component(str).gsub("+", "%20")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def safe_path(requested)
|
|
17
|
+
base = File.realpath(root_dir)
|
|
18
|
+
full = File.join(base, requested)
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
real = File.realpath(full)
|
|
22
|
+
rescue Errno::ENOENT
|
|
23
|
+
halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless real.start_with?(base)
|
|
27
|
+
halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
relative = real.sub("#{base}/", "")
|
|
31
|
+
first_segment = relative.split("/").first
|
|
32
|
+
if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
33
|
+
halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
real
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|