markdownr 0.6.14 → 0.6.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1a7ba09af27621d312b09f8fbf2d590664c9265762e40705487e8efdc88851b
4
- data.tar.gz: 70e8227e25b38fbf729c0d1d84a7e48a1f874a4306cfb26c13e1e897f8137da4
3
+ metadata.gz: 1a2282422f5341fe474b7b7e34af4387a7d6784b1945501f969ea3098e768377
4
+ data.tar.gz: 45b589b50541dc8e88999cba5df250047a0a298d00bbc2b54bb5d7dd5868009d
5
5
  SHA512:
6
- metadata.gz: 933b70b7b1b9187e84f4e0a5f80a74be842d874476c1119d47571a629d98be0a81b6888a9b32e74df4a5e9ef976b79dbe196b6d0e94659498949ca990afb1289
7
- data.tar.gz: 2e33af70f39e9dbbb29e6a38fdcb7b4bacb8838511b640ada29048ba4bcdd02d424c11f19d717e68eb42ded4c24e543c7db94ab246680274ecee2b75af00b486
6
+ metadata.gz: 56e7530f96dd2622ad883c0fdf9ee83d6731f8ce372476907a2ea7f22979e739e13f630f3ee0b121f39d3d3192f702ca27434de441ae59cd11f3515054f62c8d
7
+ data.tar.gz: d0831ec05ccd0e1e57dfe07f57dd3ba6ccca5d7d958c7ddf72e578995dbd4ea9ce911eb7af8e6fac96553b32c76221369338e521fab88c67ac5839d8c2d932cb
@@ -47,6 +47,10 @@ module MarkdownServer
47
47
  @@strongs_fetched_at = nil
48
48
  DICTIONARY_TTL = 3600 # 1 hour
49
49
 
50
+ # CSS cache for external stylesheets (keyed by absolute URL)
51
+ @@css_cache = {}
52
+ CSS_TTL = 3600 # 1 hour
53
+
50
54
  def self.load_plugins!
51
55
  Dir[File.join(__dir__, "plugins", "*", "plugin.rb")].sort.each { |f| require f }
52
56
  set :plugins, PluginRegistry.load_plugins(settings.root_dir, settings.plugin_overrides)
@@ -568,7 +572,7 @@ module MarkdownServer
568
572
 
569
573
  def inject_strongs_urls(html)
570
574
  return html unless settings.dictionary_url
571
- html.gsub(/<span\s+class="subst"([^>]*data-strongs="([^"]+)"[^>]*)>/) do
575
+ html = html.gsub(/<span\s+class="subst"([^>]*data-strongs="([^"]+)"[^>]*)>/) do
572
576
  attrs, strongs = $1, $2
573
577
  popup_url = strongs_popup_url(strongs)
574
578
  if popup_url && !attrs.include?("data-popup-url")
@@ -577,6 +581,16 @@ module MarkdownServer
577
581
  $&
578
582
  end
579
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
580
594
  end
581
595
 
582
596
  # Tags kept as-is (attributes stripped)
@@ -590,6 +604,26 @@ module MarkdownServer
590
604
  STRIP_FULL = %w[script style nav header footer form input
591
605
  button select textarea svg iframe noscript].to_set
592
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
+
593
627
  def fetch_external_page(url_str)
594
628
  uri = URI.parse(url_str)
595
629
  return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
@@ -865,10 +899,10 @@ module MarkdownServer
865
899
  info_html + infl_html + usage_html + conc_html
866
900
  end
867
901
 
868
- def scrip_html(html)
902
+ def scrip_html(html, source_url = nil)
869
903
  # Extract the passage content block
870
904
  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?
905
+ return page_html(html, source_url) if content.empty?
872
906
 
873
907
  # Add data-verse attributes to verse spans for popup scrolling.
874
908
  # Verse numbers live in <sup class="verse-number">N </sup> inside verse spans.
@@ -876,39 +910,30 @@ module MarkdownServer
876
910
  %(<sup class="verse-number" data-verse="#{$1}">#{$1} </sup>)
877
911
  end
878
912
 
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
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
909
934
 
910
935
  content = inject_strongs_urls(content)
911
- css + '<div style="font-family:Georgia,serif;line-height:1.7">' + content + "</div>"
936
+ css_blocks + '<div style="font-family:Georgia,serif;line-height:1.7">' + content + "</div>"
912
937
  end
913
938
 
914
939
  def inline_directory_html(dir_path, relative_dir)
@@ -1315,7 +1340,7 @@ btn._loading=false;btn.style.opacity='1';});})(this)">&#128266;</button>)
1315
1340
  JSON.dump({ title: title, html: markdownr_html(html) })
1316
1341
  elsif url.match?(/scrip\.risensavior\.com/i)
1317
1342
  title = page_title(html).sub(/ [-–] .*/, "").strip
1318
- JSON.dump({ title: title, html: scrip_html(html) })
1343
+ JSON.dump({ title: title, html: scrip_html(html, url) })
1319
1344
  else
1320
1345
  title = page_title(html).sub(/ [-–] .*/, "").strip
1321
1346
  JSON.dump({ title: title, html: page_html(html, url) })
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.6.14"
2
+ VERSION = "0.6.17"
3
3
  end
data/views/layout.erb CHANGED
@@ -2167,6 +2167,25 @@
2167
2167
  }
2168
2168
  }
2169
2169
 
2170
+ // StudyLight pronunciation: play audio instead of popup
2171
+ function playStudylightPronunc(anchor) {
2172
+ var href = anchor.getAttribute('href') || '';
2173
+ if (!anchor.classList.contains('pronunc-link')) return false;
2174
+ var m = href.match(/studylight\.org\/lexicons\/eng\/(hebrew|greek)\/(\d+)\.html/);
2175
+ if (!m) return false;
2176
+ var audioUrl = 'https://www.studylight.org/multi-media/audio/lexicons/eng/' + m[1] + '.html?n=' + m[2];
2177
+ if (!anchor._slAudio) anchor._slAudio = new Audio(audioUrl);
2178
+ var wordEl = anchor.closest('.word');
2179
+ if (wordEl) {
2180
+ wordEl.classList.add('audio-playing');
2181
+ anchor._slAudio.onended = function() { wordEl.classList.remove('audio-playing'); };
2182
+ anchor._slAudio.onerror = function() { wordEl.classList.remove('audio-playing'); };
2183
+ }
2184
+ anchor._slAudio.currentTime = 0;
2185
+ anchor._slAudio.play();
2186
+ return true;
2187
+ }
2188
+
2170
2189
  // Left-click on desktop
2171
2190
  document.addEventListener('click', function(e) {
2172
2191
  if (popup && popup.contains(e.target)) return;
@@ -2174,6 +2193,7 @@
2174
2193
  if (!anchor) { hidePopup(); return; }
2175
2194
  var href = anchor.getAttribute('href');
2176
2195
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
2196
+ if (playStudylightPronunc(anchor)) { e.preventDefault(); return; }
2177
2197
  if (!anchor.closest('.md-content') && !anchor.closest('.frontmatter')) { hidePopup(); return; }
2178
2198
  if (!shouldPopup(href)) { hidePopup(); return; }
2179
2199
  e.preventDefault();
@@ -2197,6 +2217,7 @@
2197
2217
  if (!anchor) { hidePopup(); return; }
2198
2218
  var href = anchor.getAttribute('href');
2199
2219
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
2220
+ if (playStudylightPronunc(anchor)) { e.preventDefault(); return; }
2200
2221
  if (!anchor.closest('.md-content') && !anchor.closest('.frontmatter')) { hidePopup(); return; }
2201
2222
  if (!shouldPopup(href)) { hidePopup(); return; }
2202
2223
  e.preventDefault();
@@ -2219,6 +2240,7 @@
2219
2240
  var href = a.getAttribute('href');
2220
2241
  if (!href || isAnchorOnly(href)) return;
2221
2242
  if (!shouldPopup(href)) return;
2243
+ if (a.classList.contains('pronunc-link') && /studylight\.org\/lexicons\/eng\//.test(href)) return;
2222
2244
  a.addEventListener('mouseenter', function(e) {
2223
2245
  clearTimeout(hoverTimer);
2224
2246
  if (popup) return;
@@ -1,4 +1,22 @@
1
1
  <style>
2
+ /* Word highlight during verse audio playback */
3
+ .word.audio-playing {
4
+ background: #fef08a;
5
+ border-radius: 3px;
6
+ transition: background 0.15s ease;
7
+ }
8
+
9
+ /* Interlinear translit-link with dictionary definition available */
10
+ .word[data-popup-url] .translit-link {
11
+ color: #7b3fa0;
12
+ text-decoration: underline dotted #b07fd4 1px;
13
+ text-underline-offset: 2px;
14
+ }
15
+ .word[data-popup-url] .translit-link:hover {
16
+ color: #5a2c78;
17
+ text-decoration: underline solid #5a2c78 1px;
18
+ }
19
+
2
20
  /* Bible Gateway citation links */
3
21
  a[href*="biblegateway.com"],
4
22
  a[href*="scrip.risensavior.com"] {
@@ -197,6 +215,26 @@
197
215
  .link-ctx-popup-body th, .link-ctx-popup-body td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
198
216
  .link-ctx-popup-body th { background: #f5f0e4; }
199
217
 
218
+ /*
219
+ * Reset interlinear .verse flex layout back to inline for passage popups.
220
+ * Interlinear pages define .verse { display: flex; border-bottom: ... } which
221
+ * would break inline passage content rendered inside a popup.
222
+ */
223
+ .link-ctx-popup-body .verse { display: inline; flex-wrap: initial; align-items: initial; gap: initial; margin: 0; padding: 0; border-bottom: none; }
224
+
225
+ /*
226
+ * Substitution mode for popups: force translation-only.
227
+ * The source CSS uses body.show-t / body.show-l / etc. to toggle modes, but
228
+ * those body classes leak into popup content. Override with !important to
229
+ * ensure only the translation span is visible inside popups.
230
+ */
231
+ .link-ctx-popup-body .subst-x { display: inline !important; color: #1a5276; font-style: italic; }
232
+ .link-ctx-popup-body .subst-t,
233
+ .link-ctx-popup-body .subst-l,
234
+ .link-ctx-popup-body .subst-o,
235
+ .link-ctx-popup-body .subst-p,
236
+ .link-ctx-popup-body .subst-s { display: none !important; }
237
+
200
238
  /* Blue Letter Bible popup tables */
201
239
  .blb-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-bottom: 0.6rem; }
202
240
  .blb-table th, .blb-table td { padding: 3px 7px; border: 1px solid #ddd; }
@@ -731,6 +769,25 @@
731
769
  }
732
770
  }
733
771
 
772
+ // StudyLight pronunciation: play audio instead of popup
773
+ function playStudylightPronunc(anchor) {
774
+ var href = anchor.getAttribute('href') || '';
775
+ if (!anchor.classList.contains('pronunc-link')) return false;
776
+ var m = href.match(/studylight\.org\/lexicons\/eng\/(hebrew|greek)\/(\d+)\.html/);
777
+ if (!m) return false;
778
+ var audioUrl = 'https://www.studylight.org/multi-media/audio/lexicons/eng/' + m[1] + '.html?n=' + m[2];
779
+ if (!anchor._slAudio) anchor._slAudio = new Audio(audioUrl);
780
+ var wordEl = anchor.closest('.word');
781
+ if (wordEl) {
782
+ wordEl.classList.add('audio-playing');
783
+ anchor._slAudio.onended = function() { wordEl.classList.remove('audio-playing'); };
784
+ anchor._slAudio.onerror = function() { wordEl.classList.remove('audio-playing'); };
785
+ }
786
+ anchor._slAudio.currentTime = 0;
787
+ anchor._slAudio.play();
788
+ return true;
789
+ }
790
+
734
791
  // Left-click on desktop
735
792
  document.addEventListener('click', function(e) {
736
793
  if (popup && popup.contains(e.target)) return;
@@ -738,6 +795,7 @@
738
795
  if (!anchor) { hidePopup(); return; }
739
796
  var href = anchor.getAttribute('href');
740
797
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
798
+ if (playStudylightPronunc(anchor)) { e.preventDefault(); return; }
741
799
  if (!shouldPopup(href)) { hidePopup(); return; }
742
800
  e.preventDefault();
743
801
  handleLink(anchor, e.clientX, e.clientY, false);
@@ -761,6 +819,7 @@
761
819
  if (!anchor) { hidePopup(); return; }
762
820
  var href = anchor.getAttribute('href');
763
821
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
822
+ if (playStudylightPronunc(anchor)) { e.preventDefault(); return; }
764
823
  if (!shouldPopup(href)) { hidePopup(); return; }
765
824
  e.preventDefault();
766
825
  var touch = e.changedTouches[0];
@@ -778,6 +837,7 @@
778
837
  var href = a.getAttribute('href');
779
838
  if (!href || isAnchorOnly(href)) return;
780
839
  if (!shouldPopup(href)) return;
840
+ if (a.classList.contains('pronunc-link') && /studylight\.org\/lexicons\/eng\//.test(href)) return;
781
841
 
782
842
  a.addEventListener('mouseenter', function(e) {
783
843
  clearTimeout(hoverTimer);
@@ -989,6 +1049,15 @@
989
1049
  });
990
1050
  })();
991
1051
 
1052
+ // Rewrite translit-link hrefs for words with dictionary definitions
1053
+ // so the existing popup system uses the dictionary URL instead of BLB
1054
+ (function() {
1055
+ document.querySelectorAll('.word[data-popup-url]').forEach(function(word) {
1056
+ var link = word.querySelector('.translit-link');
1057
+ if (link) link.setAttribute('href', word.dataset.popupUrl);
1058
+ });
1059
+ })();
1060
+
992
1061
  })();
993
1062
 
994
1063
  // Full-page verse highlighting from URL hash (#vN or #vN-M)
@@ -1027,4 +1096,102 @@
1027
1096
  if (toolbar) top -= toolbar.offsetHeight + 4;
1028
1097
  window.scrollTo({ top: top, behavior: 'instant' });
1029
1098
  })();
1099
+
1100
+ // Inject verse speaker icon for interlinear pages
1101
+ (function() {
1102
+ var verses = document.querySelectorAll('.verse[data-verse]');
1103
+ if (!verses.length) return;
1104
+
1105
+ function audioUrlFromLink(a) {
1106
+ if (!a) return null;
1107
+ var href = a.getAttribute('href') || '';
1108
+ var m = href.match(/studylight\.org\/lexicons\/eng\/(hebrew|greek)\/(\d+)\.html/);
1109
+ if (!m) return null;
1110
+ return 'https://www.studylight.org/multi-media/audio/lexicons/eng/' + m[1] + '.html?n=' + m[2];
1111
+ }
1112
+
1113
+ verses.forEach(function(verse) {
1114
+ var verseNum = verse.querySelector('.verse-num');
1115
+ if (!verseNum) return;
1116
+ var words = verse.querySelectorAll('.word');
1117
+ if (!words.length) return;
1118
+
1119
+ // Collect audio URLs paired with their word elements
1120
+ var items = [];
1121
+ words.forEach(function(w) {
1122
+ var link = w.querySelector('.pronunc-link');
1123
+ var url = audioUrlFromLink(link);
1124
+ if (url) items.push({ word: w, url: url });
1125
+ });
1126
+ if (!items.length) return;
1127
+
1128
+ // Create a column-flex container to replace the bare verse-num,
1129
+ // with the number at top (row 1) and speaker icon at row 5
1130
+ var wrapper = document.createElement('span');
1131
+ wrapper.style.cssText = 'display:flex;flex-direction:column;align-self:stretch;align-items:center;margin-right:4px;';
1132
+
1133
+ // Row 1: verse number
1134
+ verseNum.style.cssText += ';margin:0;';
1135
+ wrapper.appendChild(verseNum);
1136
+
1137
+ // Spacer to push icon down to row 5
1138
+ var spacer = document.createElement('span');
1139
+ spacer.style.cssText = 'flex:1;';
1140
+ wrapper.appendChild(spacer);
1141
+
1142
+ // Row 5: speaker icon
1143
+ var btn = document.createElement('button');
1144
+ btn.textContent = '\u{1F50A}';
1145
+ btn.title = 'Audio from the excellent studylight.org';
1146
+ btn.style.cssText = 'background:none;border:none;cursor:pointer;padding:0;font-size:1em;line-height:1;opacity:0.6;';
1147
+ btn.addEventListener('mouseleave', function() { if (!btn._playing) btn.style.opacity = '0.6'; });
1148
+
1149
+ var preloaded = false;
1150
+ function preloadAll() {
1151
+ if (preloaded) return;
1152
+ preloaded = true;
1153
+ items.forEach(function(item) {
1154
+ var a = new Audio(item.url);
1155
+ a.preload = 'auto';
1156
+ a.load();
1157
+ item.audio = a;
1158
+ });
1159
+ }
1160
+ // Start preloading on hover so audio is ready before click
1161
+ btn.addEventListener('mouseenter', function() {
1162
+ btn.style.opacity = '1';
1163
+ preloadAll();
1164
+ });
1165
+
1166
+ btn.addEventListener('click', function() {
1167
+ if (btn._playing) return;
1168
+ btn._playing = true;
1169
+ btn.style.opacity = '1';
1170
+ preloadAll();
1171
+
1172
+ var i = 0;
1173
+ function playNext() {
1174
+ if (i >= items.length) {
1175
+ btn._playing = false;
1176
+ btn.style.opacity = '0.6';
1177
+ return;
1178
+ }
1179
+ var item = items[i];
1180
+ item.word.classList.add('audio-playing');
1181
+ item.audio.currentTime = 0;
1182
+ var advance = function() {
1183
+ item.word.classList.remove('audio-playing');
1184
+ i++; playNext();
1185
+ };
1186
+ item.audio.onended = advance;
1187
+ item.audio.onerror = advance;
1188
+ item.audio.play().catch(advance);
1189
+ }
1190
+ playNext();
1191
+ });
1192
+
1193
+ wrapper.appendChild(btn);
1194
+ verse.insertBefore(wrapper, verse.firstChild);
1195
+ });
1196
+ })();
1030
1197
  </script>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markdownr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.14
4
+ version: 0.6.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn