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.
@@ -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(/&amp;/i, "&").gsub(/&lt;/i, "<")
87
+ .gsub(/&gt;/i, ">").gsub(/&quot;/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(/&nbsp;/i, " ").gsub(/&amp;/i, "&").gsub(/&lt;/i, "<").gsub(/&gt;/i, ">")
138
+ .gsub(/&quot;/i, '"').gsub(/&apos;/i, "'")
139
+ .gsub(/&mdash;/i, "—").gsub(/&ndash;/i, "–").gsub(/&hellip;/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(/&amp;/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