markdownr 0.6.17 → 0.7.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/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
data/lib/markdown_server/app.rb
CHANGED
|
@@ -11,10 +11,18 @@ require "set"
|
|
|
11
11
|
require "net/http"
|
|
12
12
|
require "base64"
|
|
13
13
|
require_relative "plugin"
|
|
14
|
+
require_relative "helpers/path_helpers"
|
|
15
|
+
require_relative "helpers/formatting_helpers"
|
|
16
|
+
require_relative "helpers/markdown_helpers"
|
|
17
|
+
require_relative "helpers/search_helpers"
|
|
18
|
+
require_relative "helpers/fetch_helpers"
|
|
19
|
+
require_relative "helpers/admin_helpers"
|
|
14
20
|
|
|
15
21
|
module MarkdownServer
|
|
22
|
+
EXCLUDED = %w[.git .obsidian __pycache__ .DS_Store node_modules .claude].freeze
|
|
23
|
+
|
|
16
24
|
class App < Sinatra::Base
|
|
17
|
-
EXCLUDED =
|
|
25
|
+
EXCLUDED = MarkdownServer::EXCLUDED
|
|
18
26
|
|
|
19
27
|
set :views, File.expand_path("../../views", __dir__)
|
|
20
28
|
|
|
@@ -39,1019 +47,24 @@ module MarkdownServer
|
|
|
39
47
|
set :popup_external, true
|
|
40
48
|
set :popup_external_domains, []
|
|
41
49
|
set :dictionary_url, nil
|
|
50
|
+
set :plugin_dirs, []
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
# Server-side Strong's dictionary cache
|
|
45
|
-
@@strongs_cache = nil
|
|
46
|
-
@@strongs_cache_url = nil
|
|
47
|
-
@@strongs_fetched_at = nil
|
|
48
|
-
DICTIONARY_TTL = 3600 # 1 hour
|
|
49
|
-
|
|
50
|
-
# CSS cache for external stylesheets (keyed by absolute URL)
|
|
51
|
-
@@css_cache = {}
|
|
52
|
-
CSS_TTL = 3600 # 1 hour
|
|
53
|
-
|
|
54
53
|
def self.load_plugins!
|
|
55
54
|
Dir[File.join(__dir__, "plugins", "*", "plugin.rb")].sort.each { |f| require f }
|
|
56
|
-
set :plugins, PluginRegistry.load_plugins(
|
|
55
|
+
set :plugins, PluginRegistry.load_plugins(
|
|
56
|
+
settings.root_dir,
|
|
57
|
+
settings.plugin_overrides,
|
|
58
|
+
plugin_dirs: settings.plugin_dirs
|
|
59
|
+
)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
helpers
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
CGI.escapeHTML(text.to_s)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def encode_path_component(str)
|
|
69
|
-
URI.encode_www_form_component(str).gsub("+", "%20")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def safe_path(requested)
|
|
73
|
-
base = File.realpath(root_dir)
|
|
74
|
-
full = File.join(base, requested)
|
|
75
|
-
|
|
76
|
-
begin
|
|
77
|
-
real = File.realpath(full)
|
|
78
|
-
rescue Errno::ENOENT
|
|
79
|
-
halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
unless real.start_with?(base)
|
|
83
|
-
halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
relative = real.sub("#{base}/", "")
|
|
87
|
-
first_segment = relative.split("/").first
|
|
88
|
-
if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
89
|
-
halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
real
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def format_size(bytes)
|
|
96
|
-
if bytes < 1024
|
|
97
|
-
"#{bytes} B"
|
|
98
|
-
elsif bytes < 1024 * 1024
|
|
99
|
-
"%.1f KB" % (bytes / 1024.0)
|
|
100
|
-
else
|
|
101
|
-
"%.1f MB" % (bytes / (1024.0 * 1024))
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def format_date(time)
|
|
106
|
-
time.strftime("%Y-%m-%d %H:%M")
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def icon_for(entry_name, is_dir)
|
|
110
|
-
if is_dir
|
|
111
|
-
"\u{1F4C1}"
|
|
112
|
-
else
|
|
113
|
-
ext = File.extname(entry_name).downcase
|
|
114
|
-
case ext
|
|
115
|
-
when ".md" then "\u{1F4DD}"
|
|
116
|
-
when ".pdf" then "\u{1F4D5}"
|
|
117
|
-
when ".json" then "\u{1F4CB}"
|
|
118
|
-
when ".py" then "\u{1F40D}"
|
|
119
|
-
when ".rb" then "\u{1F48E}"
|
|
120
|
-
when ".csv" then "\u{1F4CA}"
|
|
121
|
-
when ".epub" then "\u{1F4D6}"
|
|
122
|
-
else "\u{1F4C4}"
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def breadcrumbs(path)
|
|
128
|
-
parts = path.split("/").reject(&:empty?)
|
|
129
|
-
crumbs = [{ name: "home", href: "/browse/" }]
|
|
130
|
-
parts.each_with_index do |part, i|
|
|
131
|
-
href = "/browse/" + parts[0..i].map { |p| encode_path_component(p) }.join("/") + "/"
|
|
132
|
-
crumbs << { name: part, href: href }
|
|
133
|
-
end
|
|
134
|
-
crumbs
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def parse_frontmatter(content)
|
|
138
|
-
if content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
|
|
139
|
-
begin
|
|
140
|
-
meta = YAML.safe_load($1, permitted_classes: [Date, Time])
|
|
141
|
-
body = $2
|
|
142
|
-
[meta, body]
|
|
143
|
-
rescue => e
|
|
144
|
-
[nil, content]
|
|
145
|
-
end
|
|
146
|
-
else
|
|
147
|
-
[nil, content]
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def render_markdown(text)
|
|
152
|
-
# Convert mermaid code fences to raw HTML divs before Kramdown so Rouge
|
|
153
|
-
# never touches them and the content is preserved exactly for Mermaid.js.
|
|
154
|
-
text = text.gsub(/^```mermaid[ \t]*\r?\n([\s\S]*?)^```[ \t]*(\r?\n|\z)/m) do
|
|
155
|
-
"<div class=\"mermaid\">\n#{h($1.rstrip)}\n</div>\n\n"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Run plugin markdown transformations (e.g. Bible citation auto-linking)
|
|
159
|
-
settings.plugins.each { |p| text = p.transform_markdown(text) }
|
|
160
|
-
|
|
161
|
-
# Process wiki links BEFORE Kramdown so that | isn't consumed as
|
|
162
|
-
# a GFM table delimiter.
|
|
163
|
-
text = text.gsub(/\[\[([^\]]+)\]\]/) do
|
|
164
|
-
raw = $1
|
|
165
|
-
if raw.include?("|")
|
|
166
|
-
target, display = raw.split("|", 2)
|
|
167
|
-
else
|
|
168
|
-
target = raw
|
|
169
|
-
display = nil
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
if target.start_with?("#")
|
|
173
|
-
heading_text = target[1..]
|
|
174
|
-
anchor = heading_text.downcase.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
|
|
175
|
-
label = display || heading_text
|
|
176
|
-
%(<a class="wiki-link" href="##{h(anchor)}">#{h(label)}</a>)
|
|
177
|
-
else
|
|
178
|
-
file_part, anchor_part = target.split("#", 2)
|
|
179
|
-
anchor_suffix = anchor_part ? "##{anchor_part.downcase.gsub(/\s+/, '-').gsub(/[^\w-]/, '')}" : ""
|
|
180
|
-
resolved = resolve_wiki_link(file_part)
|
|
181
|
-
label = display || target
|
|
182
|
-
if resolved
|
|
183
|
-
%(<a class="wiki-link" href="/browse/#{encode_path_component(resolved).gsub('%2F', '/')}#{anchor_suffix}">#{h(label)}</a>)
|
|
184
|
-
else
|
|
185
|
-
%(<span class="wiki-link broken">#{h(label)}</span>)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
html = Kramdown::Document.new(
|
|
191
|
-
text,
|
|
192
|
-
input: "GFM",
|
|
193
|
-
syntax_highlighter: "rouge",
|
|
194
|
-
syntax_highlighter_opts: { default_lang: "text" },
|
|
195
|
-
hard_wrap: settings.hard_wrap
|
|
196
|
-
).to_html
|
|
197
|
-
|
|
198
|
-
# Restore non-numeric footnote labels: Kramdown converts all footnote
|
|
199
|
-
# references to sequential numbers, but we want [^abc] to display "abc".
|
|
200
|
-
html = html.gsub(%r{<sup id="fnref:([^"]+)"[^>]*>\s*<a href="#fn:\1"[^>]*>\d+</a>\s*</sup>}m) do
|
|
201
|
-
full_match = $&
|
|
202
|
-
name = $1
|
|
203
|
-
if name =~ /\A\d+\z/
|
|
204
|
-
full_match
|
|
205
|
-
else
|
|
206
|
-
%(<sup id="fnref:#{name}" role="doc-noteref"><a href="#fn:#{name}" class="footnote" rel="footnote">#{h(name)}</a></sup>)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
210
|
-
full_match = $&
|
|
211
|
-
name = $1
|
|
212
|
-
if name =~ /\A\d+\z/
|
|
213
|
-
full_match
|
|
214
|
-
else
|
|
215
|
-
%(<li id="fn:#{name}"><p><strong>#{h(name)}:</strong> )
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def resolve_wiki_link(name)
|
|
221
|
-
filename = "#{name}.md"
|
|
222
|
-
base = File.realpath(root_dir)
|
|
223
|
-
# On case-sensitive filesystems (Linux/Docker), FNM_CASEFOLD doesn't help
|
|
224
|
-
# with directory listings. Try exact filename first, then lowercased variant.
|
|
225
|
-
candidates = [filename]
|
|
226
|
-
candidates << filename.downcase if filename != filename.downcase
|
|
227
|
-
|
|
228
|
-
# Check the current file's directory first (exact case, then case-insensitive)
|
|
229
|
-
if @current_wiki_dir
|
|
230
|
-
local_exact = nil
|
|
231
|
-
local_ci = nil
|
|
232
|
-
candidates.each do |fn|
|
|
233
|
-
Dir.glob(File.join(@current_wiki_dir, fn)).each do |path|
|
|
234
|
-
real = File.realpath(path) rescue next
|
|
235
|
-
next unless real.start_with?(base)
|
|
236
|
-
relative = real.sub("#{base}/", "")
|
|
237
|
-
first_segment = relative.split("/").first
|
|
238
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
239
|
-
if File.basename(real) == filename
|
|
240
|
-
local_exact = relative
|
|
241
|
-
break
|
|
242
|
-
else
|
|
243
|
-
local_ci ||= relative
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
break if local_exact
|
|
247
|
-
end
|
|
248
|
-
return local_exact if local_exact
|
|
249
|
-
return local_ci if local_ci
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
# Fall back to global recursive search
|
|
253
|
-
exact_match = nil
|
|
254
|
-
ci_match = nil
|
|
255
|
-
candidates.each do |fn|
|
|
256
|
-
Dir.glob(File.join(base, "**", fn)).each do |path|
|
|
257
|
-
real = File.realpath(path) rescue next
|
|
258
|
-
next unless real.start_with?(base)
|
|
259
|
-
relative = real.sub("#{base}/", "")
|
|
260
|
-
first_segment = relative.split("/").first
|
|
261
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
262
|
-
if File.basename(real) == filename
|
|
263
|
-
exact_match ||= relative
|
|
264
|
-
else
|
|
265
|
-
ci_match ||= relative
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
break if exact_match
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
exact_match || ci_match
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def render_inline_wiki_links(str)
|
|
275
|
-
result = ""
|
|
276
|
-
last_end = 0
|
|
277
|
-
str.scan(/\[\[([^\]]+)\]\]/) do |match|
|
|
278
|
-
raw = match[0]
|
|
279
|
-
m_start = $~.begin(0)
|
|
280
|
-
m_end = $~.end(0)
|
|
281
|
-
result += h(str[last_end...m_start])
|
|
282
|
-
target, display = raw.include?("|") ? raw.split("|", 2) : [raw, nil]
|
|
283
|
-
label = display || target
|
|
284
|
-
if target.start_with?("#")
|
|
285
|
-
anchor = target[1..].downcase.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
|
|
286
|
-
result += %(<a class="wiki-link" href="##{h(anchor)}">#{h(label)}</a>)
|
|
287
|
-
else
|
|
288
|
-
file_part, anchor_part = target.split("#", 2)
|
|
289
|
-
anchor_suffix = anchor_part ? "##{anchor_part.downcase.gsub(/\s+/, '-').gsub(/[^\w-]/, '')}" : ""
|
|
290
|
-
resolved = resolve_wiki_link(file_part)
|
|
291
|
-
if resolved
|
|
292
|
-
result += %(<a class="wiki-link" href="/browse/#{encode_path_component(resolved).gsub('%2F', '/')}#{anchor_suffix}">#{h(label)}</a>)
|
|
293
|
-
else
|
|
294
|
-
result += %(<span class="wiki-link broken">#{h(label)}</span>)
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
last_end = m_end
|
|
298
|
-
end
|
|
299
|
-
result += h(str[last_end..])
|
|
300
|
-
result
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def render_frontmatter_value(value)
|
|
304
|
-
case value
|
|
305
|
-
when Array
|
|
306
|
-
value.map { |v|
|
|
307
|
-
str = v.to_s
|
|
308
|
-
if str.include?("[[")
|
|
309
|
-
render_inline_wiki_links(str)
|
|
310
|
-
else
|
|
311
|
-
%(<span class="tag">#{h(str)}</span>)
|
|
312
|
-
end
|
|
313
|
-
}.join(" ")
|
|
314
|
-
when String
|
|
315
|
-
if value =~ /\Ahttps?:\/\//
|
|
316
|
-
%(<a href="#{h(value)}" target="_blank" rel="noopener">#{h(value)}</a>)
|
|
317
|
-
elsif value.include?("[[")
|
|
318
|
-
render_inline_wiki_links(value)
|
|
319
|
-
elsif value.length > 120
|
|
320
|
-
render_markdown(value)
|
|
321
|
-
else
|
|
322
|
-
h(value)
|
|
323
|
-
end
|
|
324
|
-
when Numeric, TrueClass, FalseClass
|
|
325
|
-
h(value.to_s)
|
|
326
|
-
when NilClass
|
|
327
|
-
%(<span class="empty">—</span>)
|
|
328
|
-
else
|
|
329
|
-
h(value.to_s)
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def syntax_highlight(code, language)
|
|
334
|
-
formatter = Rouge::Formatters::HTML.new
|
|
335
|
-
lexer = Rouge::Lexer.find_fancy(language) || Rouge::Lexers::PlainText.new
|
|
336
|
-
formatter.format(lexer.lex(code))
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def extract_toc(html)
|
|
340
|
-
headings = []
|
|
341
|
-
html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
|
|
342
|
-
clean_text = text.gsub(/<sup[^>]*id="fnref:[^"]*"[^>]*>.*?<\/sup>/i, "").gsub(/<[^>]+>/, "").strip
|
|
343
|
-
headings << { level: level.to_i, id: id, text: clean_text }
|
|
344
|
-
end
|
|
345
|
-
headings
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def dir_title
|
|
349
|
-
return settings.custom_title if settings.respond_to?(:custom_title) && settings.custom_title
|
|
350
|
-
File.basename(root_dir).gsub(/[-_]/, " ").gsub(/\b\w/, &:upcase)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def search_form_path(relative_path)
|
|
354
|
-
"/search/" + relative_path.split("/").map { |p| encode_path_component(p) }.join("/")
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
def parent_dir_path(relative_path)
|
|
358
|
-
parts = relative_path.split("/")
|
|
359
|
-
parts.length > 1 ? parts[0..-2].join("/") : ""
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
BINARY_EXTENSIONS = %w[
|
|
363
|
-
.png .jpg .jpeg .gif .bmp .ico .svg .webp
|
|
364
|
-
.pdf .epub .mobi
|
|
365
|
-
.zip .gz .tar .bz2 .7z .rar
|
|
366
|
-
.exe .dll .so .dylib .o
|
|
367
|
-
.mp3 .mp4 .avi .mov .wav .flac .ogg
|
|
368
|
-
.woff .woff2 .ttf .eot .otf
|
|
369
|
-
.pyc .class .beam
|
|
370
|
-
.sqlite .db
|
|
371
|
-
].freeze
|
|
372
|
-
|
|
373
|
-
MAX_SEARCH_FILES = 100
|
|
374
|
-
MAX_FILE_READ_BYTES = 512_000 # 500KB
|
|
375
|
-
CONTEXT_LINES = 2 # lines before/after match to send
|
|
376
|
-
MAX_LINE_DISPLAY = 200 # chars before truncating a line
|
|
377
|
-
|
|
378
|
-
def search_single_file(file_path, regexes)
|
|
379
|
-
base = File.realpath(root_dir)
|
|
380
|
-
begin
|
|
381
|
-
content = File.binread(file_path, MAX_FILE_READ_BYTES)
|
|
382
|
-
rescue
|
|
383
|
-
return []
|
|
384
|
-
end
|
|
385
|
-
content.force_encoding("utf-8")
|
|
386
|
-
return [] unless content.valid_encoding?
|
|
387
|
-
return [] unless regexes.all? { |re| re.match?(content) }
|
|
388
|
-
|
|
389
|
-
relative = file_path.sub("#{base}/", "")
|
|
390
|
-
lines = content.lines
|
|
391
|
-
matches = collect_matching_lines(lines, regexes)
|
|
392
|
-
[{ path: relative, matches: matches }]
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
def search_files(dir_path, regexes)
|
|
396
|
-
results = []
|
|
397
|
-
base = File.realpath(root_dir)
|
|
398
|
-
|
|
399
|
-
catch(:search_limit) do
|
|
400
|
-
walk_directory(dir_path) do |file_path|
|
|
401
|
-
throw :search_limit if results.length >= MAX_SEARCH_FILES
|
|
402
|
-
|
|
403
|
-
content = File.binread(file_path, MAX_FILE_READ_BYTES) rescue next
|
|
404
|
-
content.force_encoding("utf-8")
|
|
405
|
-
next unless content.valid_encoding?
|
|
406
|
-
|
|
407
|
-
# All regexes must match somewhere in the file
|
|
408
|
-
next unless regexes.all? { |re| re.match?(content) }
|
|
409
|
-
|
|
410
|
-
relative = file_path.sub("#{base}/", "")
|
|
411
|
-
lines = content.lines
|
|
412
|
-
matches = collect_matching_lines(lines, regexes)
|
|
413
|
-
|
|
414
|
-
results << { path: relative, matches: matches }
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
results
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def walk_directory(dir_path, &block)
|
|
422
|
-
Dir.entries(dir_path).sort.each do |entry|
|
|
423
|
-
next if entry.start_with?(".") || EXCLUDED.include?(entry)
|
|
424
|
-
full = File.join(dir_path, entry)
|
|
425
|
-
|
|
426
|
-
if File.directory?(full)
|
|
427
|
-
walk_directory(full, &block)
|
|
428
|
-
elsif File.file?(full)
|
|
429
|
-
ext = File.extname(entry).downcase
|
|
430
|
-
next if BINARY_EXTENSIONS.include?(ext)
|
|
431
|
-
block.call(full)
|
|
432
|
-
end
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def collect_matching_lines(lines, regexes)
|
|
437
|
-
match_indices = Set.new
|
|
438
|
-
lines.each_with_index do |line, i|
|
|
439
|
-
if regexes.any? { |re| re.match?(line) }
|
|
440
|
-
match_indices << i
|
|
441
|
-
end
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# Build context groups
|
|
445
|
-
groups = []
|
|
446
|
-
sorted = match_indices.sort
|
|
447
|
-
|
|
448
|
-
sorted.each do |idx|
|
|
449
|
-
range_start = [idx - CONTEXT_LINES, 0].max
|
|
450
|
-
range_end = [idx + CONTEXT_LINES, lines.length - 1].min
|
|
451
|
-
|
|
452
|
-
if groups.last && range_start <= groups.last[:end] + 1
|
|
453
|
-
groups.last[:end] = range_end
|
|
454
|
-
else
|
|
455
|
-
groups << { start: range_start, end: range_end }
|
|
456
|
-
end
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
groups.map do |g|
|
|
460
|
-
context_lines = (g[:start]..g[:end]).map do |i|
|
|
461
|
-
distance = match_indices.include?(i) ? 0 : match_indices.map { |m| (m - i).abs }.min
|
|
462
|
-
{ number: i + 1, text: lines[i].to_s.chomp, distance: distance }
|
|
463
|
-
end
|
|
464
|
-
{ lines: context_lines }
|
|
465
|
-
end
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def highlight_search_line(text, regexes, is_match)
|
|
469
|
-
# Build a combined regex with non-greedy quantifiers for shorter highlights
|
|
470
|
-
combined = Regexp.union(regexes.map { |r|
|
|
471
|
-
Regexp.new(r.source.gsub(/(?<!\\)([*+}])(?!\?)/, '\1?'), r.options)
|
|
472
|
-
})
|
|
473
|
-
|
|
474
|
-
# Truncate long lines, centering around the first match
|
|
475
|
-
prefix_trunc = false
|
|
476
|
-
suffix_trunc = false
|
|
477
|
-
if text.length > MAX_LINE_DISPLAY
|
|
478
|
-
if is_match && (m = combined.match(text))
|
|
479
|
-
center = m.begin(0) + m[0].length / 2
|
|
480
|
-
half = MAX_LINE_DISPLAY / 2
|
|
481
|
-
start = [[center - half, 0].max, [text.length - MAX_LINE_DISPLAY, 0].max].min
|
|
482
|
-
else
|
|
483
|
-
start = 0
|
|
484
|
-
end
|
|
485
|
-
prefix_trunc = start > 0
|
|
486
|
-
suffix_trunc = (start + MAX_LINE_DISPLAY) < text.length
|
|
487
|
-
text = text[start, MAX_LINE_DISPLAY]
|
|
488
|
-
end
|
|
489
|
-
|
|
490
|
-
html = ""
|
|
491
|
-
html << '<span class="truncated">...</span>' if prefix_trunc
|
|
492
|
-
if is_match
|
|
493
|
-
pieces = text.split(combined)
|
|
494
|
-
matches = text.scan(combined)
|
|
495
|
-
pieces.each_with_index do |piece, i|
|
|
496
|
-
html << h(piece)
|
|
497
|
-
html << %(<span class="highlight-match">#{h(matches[i])}</span>) if matches[i]
|
|
498
|
-
end
|
|
499
|
-
else
|
|
500
|
-
html << h(text)
|
|
501
|
-
end
|
|
502
|
-
html << '<span class="truncated">...</span>' if suffix_trunc
|
|
503
|
-
html
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
FETCH_MAX_BYTES = 512_000
|
|
507
|
-
FETCH_TIMEOUT = 5
|
|
508
|
-
|
|
509
|
-
def strongs_map
|
|
510
|
-
url = settings.dictionary_url
|
|
511
|
-
return {} unless url
|
|
512
|
-
|
|
513
|
-
now = Time.now
|
|
514
|
-
if @@strongs_cache && @@strongs_cache_url == url && @@strongs_fetched_at
|
|
515
|
-
if url.match?(/\Ahttps?:\/\//i)
|
|
516
|
-
return @@strongs_cache if (now - @@strongs_fetched_at) < DICTIONARY_TTL
|
|
517
|
-
else
|
|
518
|
-
path = File.expand_path(url, root_dir)
|
|
519
|
-
mtime = File.mtime(path) rescue nil
|
|
520
|
-
return @@strongs_cache if mtime && mtime <= @@strongs_fetched_at
|
|
521
|
-
end
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
begin
|
|
525
|
-
raw = if url.match?(/\Ahttps?:\/\//i)
|
|
526
|
-
uri = URI.parse(url)
|
|
527
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
528
|
-
http.use_ssl = (uri.scheme == "https")
|
|
529
|
-
http.open_timeout = FETCH_TIMEOUT
|
|
530
|
-
http.read_timeout = 10
|
|
531
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
532
|
-
req["Accept"] = "application/json"
|
|
533
|
-
resp = http.request(req)
|
|
534
|
-
return @@strongs_cache || {} unless resp.is_a?(Net::HTTPSuccess)
|
|
535
|
-
resp.body
|
|
536
|
-
else
|
|
537
|
-
path = File.expand_path(url, root_dir)
|
|
538
|
-
return @@strongs_cache || {} unless File.exist?(path)
|
|
539
|
-
File.read(path, encoding: "utf-8")
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
data = JSON.parse(raw)
|
|
543
|
-
url_tpl = data["url"] || ""
|
|
544
|
-
map = {}
|
|
545
|
-
%w[greek hebrew].each do |lang|
|
|
546
|
-
stems = data.dig("stems", lang)
|
|
547
|
-
next unless stems.is_a?(Hash)
|
|
548
|
-
stems.each_value do |entry|
|
|
549
|
-
sn = entry["strongs"].to_s.strip.upcase
|
|
550
|
-
next if sn.empty?
|
|
551
|
-
map[sn] = url_tpl.gsub("{filename}", entry["filename"].to_s)
|
|
552
|
-
end
|
|
553
|
-
end
|
|
554
|
-
@@strongs_cache = map
|
|
555
|
-
@@strongs_cache_url = url
|
|
556
|
-
@@strongs_fetched_at = now
|
|
557
|
-
map
|
|
558
|
-
rescue StandardError
|
|
559
|
-
@@strongs_cache || {}
|
|
560
|
-
end
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
def strongs_popup_url(strongs)
|
|
564
|
-
sn = strongs.to_s.strip.upcase
|
|
565
|
-
return nil unless sn.match?(/\A[GH]\d+\z/)
|
|
566
|
-
url = strongs_map[sn]
|
|
567
|
-
return url if url
|
|
568
|
-
# Fallback: Blue Letter Bible
|
|
569
|
-
prefix = sn.start_with?("H") ? "wlc" : "tr"
|
|
570
|
-
"https://www.blueletterbible.org/lexicon/#{sn.downcase}/nasb20/#{prefix}/0-1/"
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
def inject_strongs_urls(html)
|
|
574
|
-
return html unless settings.dictionary_url
|
|
575
|
-
html = html.gsub(/<span\s+class="subst"([^>]*data-strongs="([^"]+)"[^>]*)>/) do
|
|
576
|
-
attrs, strongs = $1, $2
|
|
577
|
-
popup_url = strongs_popup_url(strongs)
|
|
578
|
-
if popup_url && !attrs.include?("data-popup-url")
|
|
579
|
-
%(<span class="subst" data-popup-url="#{h(popup_url)}"#{attrs}>)
|
|
580
|
-
else
|
|
581
|
-
$&
|
|
582
|
-
end
|
|
583
|
-
end
|
|
584
|
-
# Interlinear .word spans: inject data-popup-url when Strong's is in dictionary
|
|
585
|
-
html.gsub(/<span\s+class="word"([^>]*data-strongs="([^"]+)"[^>]*)>/) do
|
|
586
|
-
attrs, strongs = $1, $2
|
|
587
|
-
dict_url = strongs_map[strongs.strip.upcase]
|
|
588
|
-
if dict_url && !attrs.include?("data-popup-url")
|
|
589
|
-
%(<span class="word" data-popup-url="#{h(dict_url)}"#{attrs}>)
|
|
590
|
-
else
|
|
591
|
-
$&
|
|
592
|
-
end
|
|
593
|
-
end
|
|
594
|
-
end
|
|
595
|
-
|
|
596
|
-
# Tags kept as-is (attributes stripped)
|
|
597
|
-
ALLOWED_HTML = %w[p h1 h2 h3 h4 h5 h6 blockquote ul ol li
|
|
598
|
-
pre br hr strong b em i sup sub code
|
|
599
|
-
table tr td th].to_set
|
|
600
|
-
# Block containers — replaced with a newline (content kept)
|
|
601
|
-
BLOCK_HTML = %w[div section aside figure figcaption
|
|
602
|
-
thead tbody tfoot].to_set
|
|
603
|
-
# Elements removed completely, including their content
|
|
604
|
-
STRIP_FULL = %w[script style nav header footer form input
|
|
605
|
-
button select textarea svg iframe noscript].to_set
|
|
606
|
-
|
|
607
|
-
def fetch_css(url_str)
|
|
608
|
-
cached = @@css_cache[url_str]
|
|
609
|
-
return cached[:body] if cached && (Time.now - cached[:at]) < CSS_TTL
|
|
610
|
-
|
|
611
|
-
uri = URI.parse(url_str)
|
|
612
|
-
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
613
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
614
|
-
http.use_ssl = (uri.scheme == "https")
|
|
615
|
-
http.open_timeout = FETCH_TIMEOUT
|
|
616
|
-
http.read_timeout = FETCH_TIMEOUT
|
|
617
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
618
|
-
req["Accept"] = "text/css"
|
|
619
|
-
resp = http.request(req)
|
|
620
|
-
body = resp.is_a?(Net::HTTPSuccess) ? resp.body.to_s.encode("utf-8", invalid: :replace, undef: :replace) : nil
|
|
621
|
-
@@css_cache[url_str] = { body: body, at: Time.now } if body
|
|
622
|
-
body
|
|
623
|
-
rescue
|
|
624
|
-
@@css_cache.dig(url_str, :body)
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
def fetch_external_page(url_str)
|
|
628
|
-
uri = URI.parse(url_str)
|
|
629
|
-
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
630
|
-
fetch_follow_redirects(uri, 5)
|
|
631
|
-
rescue
|
|
632
|
-
nil
|
|
633
|
-
end
|
|
634
|
-
|
|
635
|
-
def fetch_follow_redirects(uri, limit)
|
|
636
|
-
return nil if limit <= 0
|
|
637
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
638
|
-
http.use_ssl = (uri.scheme == "https")
|
|
639
|
-
http.open_timeout = FETCH_TIMEOUT
|
|
640
|
-
http.read_timeout = FETCH_TIMEOUT
|
|
641
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
642
|
-
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"
|
|
643
|
-
req["Accept"] = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8"
|
|
644
|
-
req["Accept-Language"] = "en-US,en;q=0.5"
|
|
645
|
-
resp = http.request(req)
|
|
646
|
-
case resp
|
|
647
|
-
when Net::HTTPSuccess
|
|
648
|
-
ct = resp["content-type"].to_s
|
|
649
|
-
return nil unless ct.match?(/html|text/i)
|
|
650
|
-
body = resp.body.to_s
|
|
651
|
-
body = body.b[0, FETCH_MAX_BYTES].force_encoding("utf-8")
|
|
652
|
-
body.encode("utf-8", invalid: :replace, undef: :replace, replace: "?")
|
|
653
|
-
when Net::HTTPRedirection
|
|
654
|
-
loc = resp["Location"].to_s
|
|
655
|
-
new_uri = (URI.parse(loc) rescue nil)
|
|
656
|
-
return nil unless new_uri
|
|
657
|
-
new_uri = uri + new_uri unless new_uri.absolute?
|
|
658
|
-
return nil unless new_uri.is_a?(URI::HTTP) || new_uri.is_a?(URI::HTTPS)
|
|
659
|
-
fetch_follow_redirects(new_uri, limit - 1)
|
|
660
|
-
end
|
|
661
|
-
rescue
|
|
662
|
-
nil
|
|
663
|
-
end
|
|
664
|
-
|
|
665
|
-
def page_title(html)
|
|
666
|
-
html.match(/<title[^>]*>(.*?)<\/title>/im)&.then { |m|
|
|
667
|
-
m[1].gsub(/<[^>]+>/, "").gsub(/&/i, "&").gsub(/</i, "<")
|
|
668
|
-
.gsub(/>/i, ">").gsub(/"/i, '"').gsub(/&#?\w+;/, "").strip
|
|
669
|
-
} || ""
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
def page_html(raw, base_url = nil)
|
|
673
|
-
w = raw.dup
|
|
674
|
-
# Remove inert elements and their entire contents
|
|
675
|
-
STRIP_FULL.each { |t| w.gsub!(/<#{t}[^>]*>.*?<\/#{t}>/im, " ") }
|
|
676
|
-
w.gsub!(/<!--.*?-->/m, " ")
|
|
677
|
-
# Remove known ad/recommendation blocks
|
|
678
|
-
w.gsub!(/Bible\s+Gateway\s+Recommends[\s\S]*?View\s+more\s+titles/i, " ")
|
|
679
|
-
# Remove BibleGateway header/toolbar noise (promo text + login/toolbar block)
|
|
680
|
-
w.gsub!(/trusted\s+resources\s+beside\s+every\s+verse[\s\S]*?Your\s+Content/i, " ")
|
|
681
|
-
w.gsub!(/Log\s+In\s*\/\s*Sign\s+Up[\s\S]*?Your\s+Content/i, " ")
|
|
682
|
-
|
|
683
|
-
# Prefer a focused content block
|
|
684
|
-
content = w.match(/<article[^>]*>(.*?)<\/article>/im)&.[](1) ||
|
|
685
|
-
w.match(/<main[^>]*>(.*?)<\/main>/im)&.[](1) ||
|
|
686
|
-
w.match(/<body[^>]*>(.*?)<\/body>/im)&.[](1) ||
|
|
687
|
-
w
|
|
688
|
-
|
|
689
|
-
# Rewrite tags: keep allowed (strip attrs), preserve <a href>, block→newline, rest→empty
|
|
690
|
-
out = content.gsub(/<(\/?)(\w+)([^>]*)>/) do
|
|
691
|
-
slash, tag, attrs = $1, $2.downcase, $3
|
|
692
|
-
if ALLOWED_HTML.include?(tag)
|
|
693
|
-
"<#{slash}#{tag}>"
|
|
694
|
-
elsif tag == "a"
|
|
695
|
-
if slash.empty?
|
|
696
|
-
m = attrs.match(/href\s*=\s*["']([^"']*)["']/i)
|
|
697
|
-
if m && !m[1].match?(/\Ajavascript:/i)
|
|
698
|
-
href = m[1].gsub(/&/i, "&")
|
|
699
|
-
if base_url && href !~ /\Ahttps?:\/\//i && !href.start_with?("#")
|
|
700
|
-
href = (URI.join(base_url, href).to_s rescue href)
|
|
701
|
-
end
|
|
702
|
-
%(<a href="#{h(href)}" target="_blank" rel="noopener">)
|
|
703
|
-
else
|
|
704
|
-
""
|
|
705
|
-
end
|
|
706
|
-
else
|
|
707
|
-
"</a>"
|
|
708
|
-
end
|
|
709
|
-
elsif BLOCK_HTML.include?(tag)
|
|
710
|
-
"\n"
|
|
711
|
-
else
|
|
712
|
-
""
|
|
713
|
-
end
|
|
714
|
-
end
|
|
715
|
-
|
|
716
|
-
# Strip any preamble before the first heading (site chrome, toolbars, etc.)
|
|
717
|
-
out.sub!(/\A[\s\S]*?(?=<h[1-6]>)/i, "")
|
|
718
|
-
|
|
719
|
-
# Decode HTML entities
|
|
720
|
-
out = out
|
|
721
|
-
.gsub(/ /i, " ").gsub(/&/i, "&").gsub(/</i, "<").gsub(/>/i, ">")
|
|
722
|
-
.gsub(/"/i, '"').gsub(/'/i, "'")
|
|
723
|
-
.gsub(/—/i, "—").gsub(/–/i, "–").gsub(/…/i, "…")
|
|
724
|
-
.gsub(/&#(\d+);/) { [$1.to_i].pack("U") rescue " " }
|
|
725
|
-
.gsub(/&#x([\da-f]+);/i) { [$1.to_i(16)].pack("U") rescue " " }
|
|
726
|
-
.gsub(/&\w+;/, " ")
|
|
727
|
-
.gsub(/[ \t]+/, " ")
|
|
728
|
-
.gsub(/\n{3,}/, "\n\n")
|
|
729
|
-
.gsub(/<(\w+)>\s*<\/\1>/, "") # drop empty tags
|
|
730
|
-
.strip
|
|
731
|
-
|
|
732
|
-
# Strip all footer navigation after "Read full chapter" up to (but not including) copyright
|
|
733
|
-
out.gsub!(/(<a[^>]*>Read\s+full\s+chapter<\/a>)[\s\S]*?(?=©|Copyright\b)/i, "\\1\n")
|
|
734
|
-
|
|
735
|
-
out.length > 10_000 ? out[0, 10_000] : out
|
|
736
|
-
end
|
|
737
|
-
|
|
738
|
-
def markdownr_html(html)
|
|
739
|
-
# Extract .md-content from a trusted markdownr page, preserving HTML as-is
|
|
740
|
-
content = html[/<div[^>]+class="md-content"[^>]*>([\s\S]*?)<\/div>\s*(?:<div\s+class="frontmatter"|<\/div>\s*<\/div>|\z)/im, 1]
|
|
741
|
-
return page_html(html) unless content && content.length > 10
|
|
742
|
-
|
|
743
|
-
# Also extract frontmatter if present
|
|
744
|
-
fm = html[/<div\s+class="frontmatter">([\s\S]*?)<\/div>\s*<\/div>/im, 0] || ""
|
|
745
|
-
|
|
746
|
-
result = content.strip
|
|
747
|
-
result += "\n#{fm}" unless fm.empty?
|
|
748
|
-
result.length > 15_000 ? result[0, 15_000] : result
|
|
749
|
-
end
|
|
750
|
-
|
|
751
|
-
def blueletterbible_html(html, url)
|
|
752
|
-
base = "https://www.blueletterbible.org"
|
|
753
|
-
|
|
754
|
-
# ── Word ──────────────────────────────────────────────────────────────
|
|
755
|
-
word = html[/<h6[^>]+class="lexTitle(?:Gk|Hb)"[^>]*>(.*?)<\/h6>/im, 1]
|
|
756
|
-
&.gsub(/<[^>]+>/, "")&.strip || ""
|
|
757
|
-
|
|
758
|
-
# ── Transliteration ───────────────────────────────────────────────────
|
|
759
|
-
transliteration = html[/<div[^>]+id="lexTrans".*?<em>(.*?)<\/em>/im, 1]&.strip || ""
|
|
760
|
-
|
|
761
|
-
# ── Pronunciation + audio ─────────────────────────────────────────────
|
|
762
|
-
pronunciation = html[/class="[^"]*lexicon-pronunc[^"]*"[^>]*>\s*([^\n<]{1,50})/i, 1]&.strip || ""
|
|
763
|
-
data_pronunc = html[/data-pronunc="([a-fA-F0-9]{20,})"/i, 1] || ""
|
|
764
|
-
audio_btn = if data_pronunc.length > 10
|
|
765
|
-
au = "#{base}/lang/lexicon/lexPronouncePlayer.cfm?skin=#{data_pronunc}"
|
|
766
|
-
%(<button onclick="var a=this._a||(this._a=new Audio('#{h(au)}'));a.currentTime=0;a.play();" ) +
|
|
767
|
-
%(style="background:none;border:none;cursor:pointer;padding:0 0 0 4px;font-size:1.1em;vertical-align:middle;" title="Play pronunciation (Blue Letter Bible)">🔊</button>)
|
|
768
|
-
else
|
|
769
|
-
""
|
|
770
|
-
end
|
|
771
|
-
|
|
772
|
-
# StudyLight pronunciation button — extract Strong's number from URL
|
|
773
|
-
sl_match = url.to_s.match(%r{/lexicon/([hg])(\d+)/}i)
|
|
774
|
-
if sl_match
|
|
775
|
-
sl_lang = sl_match[1].downcase == "h" ? "hebrew" : "greek"
|
|
776
|
-
sl_num = sl_match[2]
|
|
777
|
-
sl_url = "https://www.studylight.org/multi-media/audio/lexicons/eng/#{sl_lang}.html?n=#{sl_num}"
|
|
778
|
-
audio_btn += %(<button onclick="var a=this._a||(this._a=new Audio('#{h(sl_url)}'));a.currentTime=0;a.play();" ) +
|
|
779
|
-
%(style="background:none;border:none;cursor:pointer;padding:0 0 0 4px;font-size:1.1em;vertical-align:middle;" title="Play pronunciation (StudyLight)">🔊</button>)
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
# ── Part of speech ────────────────────────────────────────────────────
|
|
783
|
-
pos = html[/<div[^>]+id="lexPart".*?small-text-right"[^>]*>(.*?)<\/div>/im, 1]
|
|
784
|
-
&.gsub(/<[^>]+>/, "")&.strip || ""
|
|
785
|
-
|
|
786
|
-
# ── Info table ────────────────────────────────────────────────────────
|
|
787
|
-
info_rows = [
|
|
788
|
-
["Word", h(word)],
|
|
789
|
-
["Transliteration", "<em>#{h(transliteration)}</em>"],
|
|
790
|
-
["Pronunciation", "#{h(pronunciation)}#{audio_btn}"],
|
|
791
|
-
["Part of Speech", h(pos)],
|
|
792
|
-
]
|
|
793
|
-
info_html = %(<table class="blb-table">) +
|
|
794
|
-
info_rows.map { |label, v|
|
|
795
|
-
%(<tr><th class="blb-th">#{h(label)}</th><td>#{v}</td></tr>)
|
|
796
|
-
}.join + "</table>"
|
|
797
|
-
|
|
798
|
-
# ── Inflections ───────────────────────────────────────────────────────
|
|
799
|
-
infl_html = ""
|
|
800
|
-
if (m = html.match(/<div\s[^>]*id="greek-tr-inflections"[^>]*>/im))
|
|
801
|
-
after_infl = html[m.end(0)..]
|
|
802
|
-
stop = after_infl.index(/<div\s[^>]*id="greek-(?:mgnt|lxx)-inflections"/i) || after_infl.length
|
|
803
|
-
infl_section = after_infl[0...stop]
|
|
804
|
-
inflections = []
|
|
805
|
-
infl_section.scan(/<div\s[^>]*class="greekInflection"[^>]*>(.*?)<\/div>\s*<\/div>/im) do |mv|
|
|
806
|
-
chunk = mv[0]
|
|
807
|
-
href = chunk[/href="([^"]+)"/i, 1]
|
|
808
|
-
gk = chunk[/<span[^>]+class="Gk"[^>]*>(.*?)<\/span>/im, 1]&.gsub(/<[^>]+>/, "")&.strip || ""
|
|
809
|
-
freq = chunk[/—\s*(\d+)x<\/a>/i, 1]&.to_i || 0
|
|
810
|
-
next if gk.empty? || freq.zero?
|
|
811
|
-
inflections << { word: gk, freq: freq,
|
|
812
|
-
href: href ? base + href.gsub("&", "&") : nil }
|
|
813
|
-
end
|
|
814
|
-
inflections.sort_by! { |i| -i[:freq] }
|
|
815
|
-
if inflections.any?
|
|
816
|
-
rows = inflections.map { |i|
|
|
817
|
-
match = i[:word] == word
|
|
818
|
-
cls = match ? ' class="blb-match"' : ""
|
|
819
|
-
link = i[:href] ? %(<a href="#{h(i[:href])}" target="_blank" rel="noopener">#{h(i[:word])}</a>) : h(i[:word])
|
|
820
|
-
%(<tr><td#{cls}>#{link}</td><td class="blb-right">#{i[:freq]}x</td></tr>)
|
|
821
|
-
}.join
|
|
822
|
-
infl_html = %(<h4 class="blb-heading">Inflections</h4>) +
|
|
823
|
-
%(<table class="blb-table"><thead><tr><th class="blb-th">Form</th>) +
|
|
824
|
-
%(<th class="blb-th blb-right">Count</th></tr></thead><tbody>#{rows}</tbody></table>)
|
|
825
|
-
end
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
# ── Biblical Usage ────────────────────────────────────────────────────
|
|
829
|
-
usage_html = ""
|
|
830
|
-
if (um = html.match(/<div[^>]+id="outlineBiblical"[^>]*>/im))
|
|
831
|
-
after_usage = html[um.end(0)..]
|
|
832
|
-
if (inner = after_usage.match(/\A\s*<div>([\s\S]*?)<\/div>\s*<\/div>/im))
|
|
833
|
-
cleaned = inner[1]
|
|
834
|
-
.gsub(/<(\/?)(\w+)[^>]*>/) { "<#{$1}#{$2.downcase}>" }
|
|
835
|
-
.gsub(/[ \t]+/, " ")
|
|
836
|
-
.strip
|
|
837
|
-
usage_html = %(<h4 class="blb-heading">Biblical Usage</h4><div class="blb-usage">#{cleaned}</div>)
|
|
838
|
-
end
|
|
839
|
-
end
|
|
840
|
-
|
|
841
|
-
# ── Concordance ───────────────────────────────────────────────────────
|
|
842
|
-
conc_html = ""
|
|
843
|
-
trans_name = html[/id="bibleTable"[^>]+data-translation="([^"]+)"/i, 1] || ""
|
|
844
|
-
verses = []
|
|
845
|
-
html.split(/<div\s[^>]*id="bVerse_\d+"[^>]*>/).drop(1).each do |chunk|
|
|
846
|
-
cite_href = chunk[/tablet-order-2[^>]*>[\s\S]{0,400}?href="([^"]+)"/im, 1] || ""
|
|
847
|
-
cite = chunk[/tablet-order-2[^>]*>[\s\S]{0,400}?<a[^>]*>(.*?)<\/a>/im, 1]
|
|
848
|
-
&.gsub(/<[^>]+>/, "")&.strip || ""
|
|
849
|
-
|
|
850
|
-
# Process verse HTML: highlight the matched word, strip all Strong's refs
|
|
851
|
-
raw_html = chunk[/class="EngBibleText[^"]*"[^>]*>([\s\S]*?)<\/div>/im, 1] || ""
|
|
852
|
-
raw_html.gsub!(/<img[^>]*>/, "")
|
|
853
|
-
raw_html.gsub!(/<a[^>]*class="hide-for-tablet"[^>]*>[\s\S]*?<\/a>/im, "")
|
|
854
|
-
raw_html.gsub!(/<span[^>]*class="hide-for-tablet"[^>]*>[\s\S]*?<\/span>/im, "")
|
|
855
|
-
# Use control-char placeholders so blb-match survives the tag-strip pass
|
|
856
|
-
verse_html = raw_html.gsub(/<span\s[^>]*class="word-phrase"[^>]*>([\s\S]*?)<\/span>/im) do
|
|
857
|
-
inner = $1
|
|
858
|
-
word = inner.sub(/<sup[\s\S]*/im, "").gsub(/<[^>]+>/, "")
|
|
859
|
-
.gsub(/ /i, " ").strip
|
|
860
|
-
inner.match?(/<sup[^>]*class="[^"]*strongs criteria[^"]*"/i) ?
|
|
861
|
-
"\x02#{word}\x03" : word
|
|
862
|
-
end
|
|
863
|
-
# Fallback for translations without word-phrase spans (NASB, ESV, etc.)
|
|
864
|
-
# The criteria word appears directly before its <sup class="strongs criteria"> tag
|
|
865
|
-
unless verse_html.include?("\x02")
|
|
866
|
-
verse_html.gsub!(/([\w]+[,;:.!?'"]*)\s*<sup[^>]*class="[^"]*strongs criteria[^"]*"[\s\S]*?<\/sup>/im) do
|
|
867
|
-
"\x02#{$1}\x03"
|
|
868
|
-
end
|
|
869
|
-
end
|
|
870
|
-
verse_html.gsub!(/<sup[^>]*>[\s\S]*?<\/sup>/im, "")
|
|
871
|
-
verse_html.gsub!(/<[^>]+>/, "")
|
|
872
|
-
verse_html.gsub!(/ /i, " ")
|
|
873
|
-
verse_html.gsub!(/&#(\d+);/) { [$1.to_i].pack("U") rescue " " }
|
|
874
|
-
verse_html.gsub!(/&#x([\da-f]+);/i) { [$1.to_i(16)].pack("U") rescue " " }
|
|
875
|
-
verse_html.gsub!(/&/, "&")
|
|
876
|
-
verse_html.gsub!(/</, "<")
|
|
877
|
-
verse_html.gsub!(/>/, ">")
|
|
878
|
-
verse_html.gsub!(/\s+/, " ")
|
|
879
|
-
verse_html.strip!
|
|
880
|
-
# Strip the mobile citation prefix ("Mat 5:17 - ") left by hide-for-tablet removal
|
|
881
|
-
verse_html.sub!(/\A#{Regexp.escape(cite)}\s*-\s*/i, "")
|
|
882
|
-
# Restore match placeholders as highlighted spans
|
|
883
|
-
verse_html.gsub!(/\x02([^\x03]*)\x03/) { %(<span class="blb-match">#{h($1.strip)}</span>) }
|
|
884
|
-
|
|
885
|
-
next if cite.empty? || verse_html.empty?
|
|
886
|
-
full_href = cite_href.empty? ? nil : (cite_href.start_with?("http") ? cite_href : base + cite_href)
|
|
887
|
-
verses << { cite: cite, verse_html: verse_html, href: full_href }
|
|
888
|
-
end
|
|
889
|
-
if verses.any?
|
|
890
|
-
heading = trans_name.empty? ? "Concordance" : "Concordance (#{h(trans_name)})"
|
|
891
|
-
rows = verses.map { |v|
|
|
892
|
-
link = v[:href] ? %(<a href="#{h(v[:href])}" target="_blank" rel="noopener">#{h(v[:cite])}</a>) : h(v[:cite])
|
|
893
|
-
%(<tr><td class="blb-nowrap">#{link}</td><td>#{v[:verse_html]}</td></tr>)
|
|
894
|
-
}.join
|
|
895
|
-
conc_html = %(<h4 class="blb-heading">#{heading}</h4>) +
|
|
896
|
-
%(<table class="blb-table"><tbody>#{rows}</tbody></table>)
|
|
897
|
-
end
|
|
898
|
-
|
|
899
|
-
info_html + infl_html + usage_html + conc_html
|
|
900
|
-
end
|
|
901
|
-
|
|
902
|
-
def scrip_html(html, source_url = nil)
|
|
903
|
-
# Extract the passage content block
|
|
904
|
-
content = html[/<div\s+class="passage-text[^"]*"[^>]*>([\s\S]*)<\/div>\s*(?:<nav|<script|<style|\z)/im, 1] || ""
|
|
905
|
-
return page_html(html, source_url) if content.empty?
|
|
906
|
-
|
|
907
|
-
# Add data-verse attributes to verse spans for popup scrolling.
|
|
908
|
-
# Verse numbers live in <sup class="verse-number">N </sup> inside verse spans.
|
|
909
|
-
content = content.gsub(/<sup class="verse-number">(\d+)[^<]*<\/sup>/) do
|
|
910
|
-
%(<sup class="verse-number" data-verse="#{$1}">#{$1} </sup>)
|
|
911
|
-
end
|
|
912
|
-
|
|
913
|
-
# Fetch referenced stylesheets from the source page and inline them
|
|
914
|
-
css_blocks = ""
|
|
915
|
-
if source_url
|
|
916
|
-
base_uri = URI.parse(source_url) rescue nil
|
|
917
|
-
html.scan(/<link[^>]+rel=["']stylesheet["'][^>]*>/i).each do |tag|
|
|
918
|
-
href = tag[/href=["']([^"']+)["']/i, 1]
|
|
919
|
-
next unless href
|
|
920
|
-
abs = (base_uri ? (URI.join(base_uri, href).to_s rescue nil) : nil)
|
|
921
|
-
next unless abs
|
|
922
|
-
css_body = fetch_css(abs)
|
|
923
|
-
# Resolve @import within the CSS (one level deep)
|
|
924
|
-
if css_body
|
|
925
|
-
css_dir = abs.sub(%r{/[^/]*\z}, "/")
|
|
926
|
-
css_body = css_body.gsub(/@import\s+url\(["']?([^"')]+)["']?\)\s*;/) do
|
|
927
|
-
import_url = (URI.join(css_dir, $1).to_s rescue nil)
|
|
928
|
-
import_url ? (fetch_css(import_url) || "") : ""
|
|
929
|
-
end
|
|
930
|
-
css_blocks += "<style>#{css_body}</style>\n"
|
|
931
|
-
end
|
|
932
|
-
end
|
|
933
|
-
end
|
|
934
|
-
|
|
935
|
-
content = inject_strongs_urls(content)
|
|
936
|
-
css_blocks + '<div style="font-family:Georgia,serif;line-height:1.7">' + content + "</div>"
|
|
937
|
-
end
|
|
938
|
-
|
|
939
|
-
def inline_directory_html(dir_path, relative_dir)
|
|
940
|
-
entries = Dir.entries(dir_path).reject { |e| e.start_with?(".") || EXCLUDED.include?(e) }
|
|
941
|
-
items = entries.map do |name|
|
|
942
|
-
stat = File.stat(File.join(dir_path, name)) rescue next
|
|
943
|
-
is_dir = stat.directory?
|
|
944
|
-
href = "/browse/" + (relative_dir.empty? ? "" : relative_dir + "/") +
|
|
945
|
-
encode_path_component(name) + (is_dir ? "/" : "")
|
|
946
|
-
{ name: name, is_dir: is_dir, href: href }
|
|
947
|
-
end.compact.sort_by { |i| [i[:is_dir] ? 0 : 1, i[:name].downcase] }
|
|
948
|
-
|
|
949
|
-
rows = items.map do |i|
|
|
950
|
-
%(<li><a href="#{h(i[:href])}"><span class="icon">#{icon_for(i[:name], i[:is_dir])}</span> ) +
|
|
951
|
-
%(#{h(i[:name])}#{i[:is_dir] ? "/" : ""}</a></li>)
|
|
952
|
-
end.join
|
|
953
|
-
%(<ul class="dir-listing">#{rows}</ul>)
|
|
954
|
-
end
|
|
955
|
-
|
|
956
|
-
def compile_regexes(query)
|
|
957
|
-
words = query.split(/\s+/).reject(&:empty?)
|
|
958
|
-
return nil if words.empty?
|
|
959
|
-
words.map { |w| Regexp.new(w, Regexp::IGNORECASE) }
|
|
960
|
-
rescue RegexpError => e
|
|
961
|
-
raise RegexpError, e.message
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
def client_ip
|
|
965
|
-
if settings.behind_proxy
|
|
966
|
-
fwd = env["HTTP_X_FORWARDED_FOR"].to_s.split(",").map(&:strip).first
|
|
967
|
-
fwd && !fwd.empty? ? fwd : env["REMOTE_ADDR"]
|
|
968
|
-
else
|
|
969
|
-
env["REMOTE_ADDR"]
|
|
970
|
-
end
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
def setup_config
|
|
974
|
-
@setup_config ||= begin
|
|
975
|
-
path = File.join(root_dir, ".setup.yml")
|
|
976
|
-
(File.exist?(path) && YAML.safe_load(File.read(path))) || {}
|
|
977
|
-
rescue StandardError
|
|
978
|
-
{}
|
|
979
|
-
end
|
|
980
|
-
end
|
|
981
|
-
|
|
982
|
-
def inject_pronunciation_icon(html, meta)
|
|
983
|
-
return html unless meta.is_a?(Hash) && meta["strongs"] && meta["language"]
|
|
984
|
-
strongs = meta["strongs"].to_s.strip
|
|
985
|
-
lang = meta["language"].to_s.strip.downcase
|
|
986
|
-
return html unless strongs.match?(/\A[GH]\d+\z/i) && %w[hebrew greek].include?(lang)
|
|
987
|
-
|
|
988
|
-
icon = %(<button class="pronunciation-btn" data-strongs="#{h(strongs.upcase)}" ) +
|
|
989
|
-
%(title="Play pronunciation (Blue Letter Bible)" ) +
|
|
990
|
-
%(style="background:none;border:none;cursor:pointer;padding:0 0 0 6px;font-size:1.1em;vertical-align:middle;line-height:1;color:#888;" ) +
|
|
991
|
-
%(onclick="(function(btn){if(btn._loading)return;var a=btn._audio;if(a){a.currentTime=0;a.play();return;}btn._loading=true;btn.style.opacity='0.4';\
|
|
992
|
-
fetch('/pronunciation?strongs='+btn.dataset.strongs).then(function(r){return r.json()}).then(function(d){\
|
|
993
|
-
if(d.audio){a=new Audio(d.audio);btn._audio=a;a.play();}btn._loading=false;btn.style.opacity='1';}).catch(function(){\
|
|
994
|
-
btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
995
|
-
|
|
996
|
-
sl_lang = lang == "hebrew" ? "hebrew" : "greek"
|
|
997
|
-
sl_num = strongs.upcase.sub(/\A[HG]/, "")
|
|
998
|
-
sl_url = "https://www.studylight.org/multi-media/audio/lexicons/eng/#{sl_lang}.html?n=#{sl_num}"
|
|
999
|
-
icon2 = %(<button class="pronunciation-btn" ) +
|
|
1000
|
-
%(title="Play pronunciation (StudyLight)" ) +
|
|
1001
|
-
%(style="background:none;border:none;cursor:pointer;padding:0 0 0 4px;font-size:1.1em;vertical-align:middle;line-height:1;color:#888;" ) +
|
|
1002
|
-
%(onclick="var a=this._a||(this._a=new Audio('#{h(sl_url)}'));a.currentTime=0;a.play();">🔊</button>)
|
|
1003
|
-
|
|
1004
|
-
icon = icon + icon2
|
|
1005
|
-
|
|
1006
|
-
# Insert before the closing tag of the first <h2>
|
|
1007
|
-
html.sub(%r{</h2>}) { " #{icon}</h2>" }
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
# Returns true when serving an HTML file that should have popup assets injected.
|
|
1011
|
-
# Add additional path prefixes here as needed.
|
|
1012
|
-
def inject_assets_for_html_path?(_relative_path)
|
|
1013
|
-
true
|
|
1014
|
-
end
|
|
1015
|
-
|
|
1016
|
-
# Injects popup CSS and JS into an HTML document before </body>.
|
|
1017
|
-
# Runs plugin HTML transformations (e.g. Bible citation auto-linking).
|
|
1018
|
-
# Falls back to appending before </html>, then to end of document.
|
|
1019
|
-
def inject_markdownr_assets(html_content)
|
|
1020
|
-
settings.plugins.each { |p| html_content = p.transform_html(html_content) }
|
|
1021
|
-
html_content = inject_strongs_urls(html_content)
|
|
1022
|
-
|
|
1023
|
-
popup_config_script = "<script>var __popupConfig = {" \
|
|
1024
|
-
"localMd:#{settings.popup_local_md}," \
|
|
1025
|
-
"localHtml:#{settings.popup_local_html}," \
|
|
1026
|
-
"external:#{settings.popup_external}," \
|
|
1027
|
-
"externalDomains:#{settings.popup_external_domains.to_json}" \
|
|
1028
|
-
"};</script>\n"
|
|
1029
|
-
assets = popup_config_script + File.read(File.join(settings.views, "popup_assets.erb"))
|
|
1030
|
-
inserted = false
|
|
1031
|
-
result = html_content.sub(/<\/(body|html)>/i) { inserted = true; "#{assets}</#{$1}>" }
|
|
1032
|
-
inserted ? result : html_content + assets
|
|
1033
|
-
end
|
|
1034
|
-
|
|
1035
|
-
def admin?
|
|
1036
|
-
return true if session[:admin]
|
|
1037
|
-
|
|
1038
|
-
adm = setup_config["admin"]
|
|
1039
|
-
return false unless adm.is_a?(Hash)
|
|
1040
|
-
|
|
1041
|
-
return true if adm["ip"].to_s.strip == client_ip
|
|
1042
|
-
|
|
1043
|
-
if adm["user"] && adm["pw"]
|
|
1044
|
-
auth = request.env["HTTP_AUTHORIZATION"].to_s
|
|
1045
|
-
if auth.start_with?("Basic ")
|
|
1046
|
-
user, pw = Base64.decode64(auth[6..]).split(":", 2)
|
|
1047
|
-
return true if user == adm["user"].to_s && pw == adm["pw"].to_s
|
|
1048
|
-
end
|
|
1049
|
-
end
|
|
1050
|
-
|
|
1051
|
-
false
|
|
1052
|
-
end
|
|
1053
|
-
|
|
1054
|
-
end
|
|
62
|
+
helpers Helpers::PathHelpers,
|
|
63
|
+
Helpers::FormattingHelpers,
|
|
64
|
+
Helpers::MarkdownHelpers,
|
|
65
|
+
Helpers::SearchHelpers,
|
|
66
|
+
Helpers::FetchHelpers,
|
|
67
|
+
Helpers::AdminHelpers
|
|
1055
68
|
|
|
1056
69
|
# Routes
|
|
1057
70
|
|
|
@@ -1137,6 +150,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1137
150
|
end
|
|
1138
151
|
|
|
1139
152
|
get "/debug/raw-fetch" do
|
|
153
|
+
halt 404, "not available" unless respond_to?(:blueletterbible_html)
|
|
1140
154
|
url = params[:url].to_s.strip
|
|
1141
155
|
halt 400, "missing ?url=" if url.empty?
|
|
1142
156
|
html = fetch_external_page(url)
|
|
@@ -1218,6 +232,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1218
232
|
end
|
|
1219
233
|
|
|
1220
234
|
get "/debug/fetch" do
|
|
235
|
+
halt 404, "not available" unless respond_to?(:blueletterbible_html)
|
|
1221
236
|
url = params[:url].to_s.strip
|
|
1222
237
|
halt 400, "missing ?url=" if url.empty?
|
|
1223
238
|
html = fetch_external_page(url)
|
|
@@ -1266,7 +281,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1266
281
|
title = (meta.is_a?(Hash) && meta["title"]) || File.basename(real, ".md")
|
|
1267
282
|
@current_wiki_dir = File.dirname(real)
|
|
1268
283
|
html = render_markdown(body)
|
|
1269
|
-
html =
|
|
284
|
+
settings.plugins.each { |p| html = p.post_render(html, meta, self) }
|
|
1270
285
|
|
|
1271
286
|
frontmatter_html = ""
|
|
1272
287
|
if meta && !meta.empty?
|
|
@@ -1280,6 +295,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1280
295
|
end
|
|
1281
296
|
|
|
1282
297
|
get "/pronunciation" do
|
|
298
|
+
halt 404 unless respond_to?(:blueletterbible_html)
|
|
1283
299
|
content_type :json
|
|
1284
300
|
strongs = params[:strongs].to_s.strip.upcase
|
|
1285
301
|
halt 400, '{"error":"invalid strongs"}' unless strongs.match?(/\A[GH]\d+\z/)
|
|
@@ -1309,8 +325,8 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1309
325
|
|
|
1310
326
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
1311
327
|
http.use_ssl = (uri.scheme == "https")
|
|
1312
|
-
http.open_timeout = FETCH_TIMEOUT
|
|
1313
|
-
http.read_timeout = FETCH_TIMEOUT
|
|
328
|
+
http.open_timeout = Helpers::FetchHelpers::FETCH_TIMEOUT
|
|
329
|
+
http.read_timeout = Helpers::FetchHelpers::FETCH_TIMEOUT
|
|
1314
330
|
req = Net::HTTP::Get.new(uri.request_uri)
|
|
1315
331
|
req["Accept"] = "application/json"
|
|
1316
332
|
resp = http.request(req)
|
|
@@ -1330,21 +346,18 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1330
346
|
html = fetch_external_page(url)
|
|
1331
347
|
halt 502, '{"error":"fetch failed"}' unless html
|
|
1332
348
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
JSON.dump({ title: title, html: markdownr_html(html) })
|
|
1341
|
-
elsif url.match?(/scrip\.risensavior\.com/i)
|
|
1342
|
-
title = page_title(html).sub(/ [-–] .*/, "").strip
|
|
1343
|
-
JSON.dump({ title: title, html: scrip_html(html, url) })
|
|
1344
|
-
else
|
|
349
|
+
result = nil
|
|
350
|
+
settings.plugins.each do |p|
|
|
351
|
+
result = p.process_fetch(url, html, self)
|
|
352
|
+
break if result
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
unless result
|
|
1345
356
|
title = page_title(html).sub(/ [-–] .*/, "").strip
|
|
1346
|
-
|
|
357
|
+
result = { title: title, html: page_html(html, url) }
|
|
1347
358
|
end
|
|
359
|
+
|
|
360
|
+
JSON.dump(result)
|
|
1348
361
|
end
|
|
1349
362
|
|
|
1350
363
|
get "/search/?*" do
|
|
@@ -1445,7 +458,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">🔊</button>)
|
|
|
1445
458
|
@meta, body = parse_frontmatter(content)
|
|
1446
459
|
@current_wiki_dir = File.dirname(real_path)
|
|
1447
460
|
@content = render_markdown(body)
|
|
1448
|
-
@content =
|
|
461
|
+
settings.plugins.each { |p| @content = p.post_render(@content, @meta, self) }
|
|
1449
462
|
relative_dir = File.dirname(relative_path)
|
|
1450
463
|
relative_dir = "" if relative_dir == "."
|
|
1451
464
|
listing = -> { inline_directory_html(File.dirname(real_path), relative_dir) }
|