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