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.
@@ -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 = %w[.git .obsidian __pycache__ .DS_Store node_modules .claude].freeze
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(settings.root_dir, settings.plugin_overrides)
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 do
60
- def root_dir
61
- settings.root_dir
62
- end
63
-
64
- def h(text)
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(/&amp;/i, "&").gsub(/&lt;/i, "<")
668
- .gsub(/&gt;/i, ">").gsub(/&quot;/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(/&amp;/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(/&nbsp;/i, " ").gsub(/&amp;/i, "&").gsub(/&lt;/i, "<").gsub(/&gt;/i, ">")
722
- .gsub(/&quot;/i, '"').gsub(/&apos;/i, "'")
723
- .gsub(/&mdash;/i, "—").gsub(/&ndash;/i, "–").gsub(/&hellip;/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)">&#128266;</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)">&#128266;</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[/&#8212;\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("&amp;", "&") : 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(/&nbsp;/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!(/&nbsp;/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!(/&amp;/, "&")
876
- verse_html.gsub!(/&lt;/, "<")
877
- verse_html.gsub!(/&gt;/, ">")
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)">&#128266;</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();">&#128266;</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)">&#128266;</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)">&#128266;</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)">&#128266;</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 = inject_pronunciation_icon(html, meta)
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)">&#128266;</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)">&#128266;</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)">&#128266;</button>)
1330
346
  html = fetch_external_page(url)
1331
347
  halt 502, '{"error":"fetch failed"}' unless html
1332
348
 
1333
- if url.match?(/blueletterbible\.org\/lexicon\//i)
1334
- raw = page_title(html)
1335
- title = raw.match(/^([GH]\d+ - \w+)/i)&.[](1)&.sub(" - ", " – ") ||
1336
- raw.sub(/ [-–] .*/, "").strip
1337
- JSON.dump({ title: title, html: blueletterbible_html(html, url) })
1338
- elsif url.match?(%r{/definitions/[^/]+\.md(\?|#|\z)}i)
1339
- title = page_title(html).sub(/ [-–] .*/, "").strip
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
- JSON.dump({ title: title, html: page_html(html, url) })
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)">&#128266;</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 = inject_pronunciation_icon(@content, @meta)
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) }