markdownr 0.5.21 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8453e4e0a362ba8e9c10523c1de11a0372971eedacaadc399a37dd3971f20a20
4
- data.tar.gz: da228300eee2d6e8e03a2d1e501a8ff6fc7ee27bb83706254a8abe7354c5e1d6
3
+ metadata.gz: d98ba12bdd31972d827eb7386c4c9c6d61c89d2364ee71165a3f9ff2ee52ce8c
4
+ data.tar.gz: 345dd8d2957592b9005dd3bb859109445787bd0668b4c6f7f128b4ad8c372aa8
5
5
  SHA512:
6
- metadata.gz: ac8aefdee7fefc43e3d9282cd612b5a0e8ac9b96e7ab7c710cc8e2d52da10e58d48b03aaf2c35938b87eed09243f98f70528c45eadc841f331ae36cc3a2c3797
7
- data.tar.gz: cabbff0b16dae2e73ad37339c66bdf231684e53469c782151bc0099414c97d02660f015b92a38f112d433e83b9286ba1a91cf5a52cf59bbc5355e2be79c82fc5
6
+ metadata.gz: 358c3263462fa864bfff96cfbc85fbf0e34363e0a9042ccdad9bbd00d173a57f4f6fd5b3d6301b4ccf1ef51bf41ebc56143dae620d8e79e4f7dfff2557623867
7
+ data.tar.gz: 19efd36dfe7838cca8c5e848c9aa10a846e75c4ff30802ebb9002b1915062f6f2d320deb555daca8a26d5a8bc4631a23810e756844d350c20ccc730deb6d5f60
data/bin/markdownr CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "optparse"
3
+ require "yaml"
3
4
  require_relative "../lib/markdown_server"
4
5
 
5
6
  options = { port: 4567, bind: "127.0.0.1", threads: 5 }
@@ -47,6 +48,10 @@ OptionParser.new do |opts|
47
48
  options[:verbose] = true
48
49
  end
49
50
 
51
+ opts.on("--no-bible-citations", "Disable Bible verse citation auto-linking") do
52
+ options[:plugin_overrides] = { "bible_citations" => { "enabled" => false } }
53
+ end
54
+
50
55
  opts.on("-v", "--version", "Show version") do
51
56
  puts "markdownr #{MarkdownServer::VERSION}"
52
57
  exit
@@ -72,6 +77,22 @@ MarkdownServer::App.set :verbose, options[:verbose] || false
72
77
  MarkdownServer::App.set :port, options[:port]
73
78
  MarkdownServer::App.set :bind, options[:bind]
74
79
  MarkdownServer::App.set :server_settings, { max_threads: options[:threads], min_threads: 1 }
80
+ MarkdownServer::App.set :plugin_overrides, options[:plugin_overrides] || {}
81
+
82
+ # Load .markdownr.yml popup settings
83
+ config_path = File.join(dir, ".markdownr.yml")
84
+ if File.exist?(config_path)
85
+ yaml = YAML.safe_load(File.read(config_path)) rescue nil
86
+ if yaml && yaml["popups"]
87
+ popups = yaml["popups"]
88
+ MarkdownServer::App.set :popup_local_md, popups.fetch("local_md", true)
89
+ MarkdownServer::App.set :popup_local_html, popups.fetch("local_html", false)
90
+ MarkdownServer::App.set :popup_external, popups.fetch("external", true)
91
+ MarkdownServer::App.set :popup_external_domains, popups.fetch("external_domains", [])
92
+ end
93
+ end
94
+
95
+ MarkdownServer::App.load_plugins!
75
96
 
76
97
  puts "Serving #{dir} on http://#{options[:bind]}:#{options[:port]}/"
77
98
  MarkdownServer::App.run!
@@ -10,14 +10,12 @@ require "pathname"
10
10
  require "set"
11
11
  require "net/http"
12
12
  require "base64"
13
- require_relative "bible_citations"
13
+ require_relative "plugin"
14
14
 
15
15
  module MarkdownServer
16
16
  class App < Sinatra::Base
17
17
  EXCLUDED = %w[.git .obsidian __pycache__ .DS_Store node_modules .claude].freeze
18
18
 
19
- # Bible citation auto-linking — see lib/markdown_server/bible_citations.rb
20
-
21
19
  set :views, File.expand_path("../../views", __dir__)
22
20
 
23
21
  configure do
@@ -34,6 +32,17 @@ module MarkdownServer
34
32
  set :verbose, false
35
33
  set :session_secret, ENV.fetch("MARKDOWNR_SESSION_SECRET", SecureRandom.hex(64))
36
34
  set :sessions, key: "markdownr_session", same_site: :strict, httponly: true
35
+ set :plugin_overrides, {}
36
+ set :plugins, []
37
+ set :popup_local_md, true
38
+ set :popup_local_html, false
39
+ set :popup_external, true
40
+ set :popup_external_domains, []
41
+ end
42
+
43
+ def self.load_plugins!
44
+ Dir[File.join(__dir__, "plugins", "*", "plugin.rb")].sort.each { |f| require f }
45
+ set :plugins, PluginRegistry.load_plugins(settings.root_dir, settings.plugin_overrides)
37
46
  end
38
47
 
39
48
  helpers do
@@ -135,11 +144,8 @@ module MarkdownServer
135
144
  "<div class=\"mermaid\">\n#{h($1.rstrip)}\n</div>\n\n"
136
145
  end
137
146
 
138
- # Auto-link bare Bible verse citations (e.g. "John 3:16" → BibleGateway link).
139
- # Skips inline code, fenced code blocks, and citations already inside a link.
140
- text = MarkdownServer.link_citations(text) do |canonical, verse, citation|
141
- "[#{citation}](#{MarkdownServer.biblegateway_url(canonical, verse)})"
142
- end
147
+ # Run plugin markdown transformations (e.g. Bible citation auto-linking)
148
+ settings.plugins.each { |p| text = p.transform_markdown(text) }
143
149
 
144
150
  # Process wiki links BEFORE Kramdown so that | isn't consumed as
145
151
  # a GFM table delimiter.
@@ -792,15 +798,18 @@ module MarkdownServer
792
798
  end
793
799
 
794
800
  # Injects popup CSS and JS into an HTML document before </body>.
795
- # Also auto-links bare Bible verse citations in HTML text nodes.
801
+ # Runs plugin HTML transformations (e.g. Bible citation auto-linking).
796
802
  # Falls back to appending before </html>, then to end of document.
797
803
  def inject_markdownr_assets(html_content)
798
- html_content = MarkdownServer.link_citations_html(html_content) do |canonical, verse, citation|
799
- url = MarkdownServer.biblegateway_url(canonical, verse)
800
- %(<a href="#{h(url)}">#{h(citation)}</a>)
801
- end
802
-
803
- assets = File.read(File.join(settings.views, "popup_assets.erb"))
804
+ settings.plugins.each { |p| html_content = p.transform_html(html_content) }
805
+
806
+ popup_config_script = "<script>var __popupConfig = {" \
807
+ "localMd:#{settings.popup_local_md}," \
808
+ "localHtml:#{settings.popup_local_html}," \
809
+ "external:#{settings.popup_external}," \
810
+ "externalDomains:#{settings.popup_external_domains.to_json}" \
811
+ "};</script>\n"
812
+ assets = popup_config_script + File.read(File.join(settings.views, "popup_assets.erb"))
804
813
  inserted = false
805
814
  result = html_content.sub(/<\/(body|html)>/i) { inserted = true; "#{assets}</#{$1}>" }
806
815
  inserted ? result : html_content + assets
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MarkdownServer
6
+ module Plugin
7
+ def self.included(klass)
8
+ PluginRegistry.register(klass)
9
+ klass.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def plugin_name
14
+ name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
15
+ end
16
+
17
+ def enabled_by_default?
18
+ false
19
+ end
20
+ end
21
+
22
+ # Called once at startup with resolved config hash
23
+ def setup(config) end
24
+
25
+ # Hook: transform raw markdown text (runs before wiki links + Kramdown)
26
+ def transform_markdown(text) text end
27
+
28
+ # Hook: transform raw HTML (runs on .html files before popup injection)
29
+ def transform_html(html) html end
30
+ end
31
+
32
+ class PluginRegistry
33
+ class << self
34
+ def registered
35
+ @registered ||= []
36
+ end
37
+
38
+ def register(klass)
39
+ registered << klass unless registered.include?(klass)
40
+ end
41
+
42
+ def load_plugins(root_dir, cli_overrides = {})
43
+ config = resolve_config(root_dir, cli_overrides)
44
+ registered.filter_map do |klass|
45
+ plugin_config = config.fetch(klass.plugin_name, {})
46
+ enabled = plugin_config.fetch("enabled", klass.enabled_by_default?)
47
+ next unless enabled
48
+ klass.new.tap { |p| p.setup(plugin_config) }
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_config(root_dir, cli_overrides)
55
+ config = {}
56
+ # Layer 1: .markdownr.yml
57
+ path = File.join(root_dir, ".markdownr.yml")
58
+ if File.exist?(path)
59
+ yaml = YAML.safe_load(File.read(path)) rescue nil
60
+ config.merge!(yaml&.dig("plugins") || {})
61
+ end
62
+ # Layer 2: env vars (MARKDOWNR_PLUGIN_BIBLE_CITATIONS_ENABLED=false)
63
+ registered.each do |klass|
64
+ prefix = "MARKDOWNR_PLUGIN_#{klass.plugin_name.upcase}_"
65
+ ENV.each do |k, v|
66
+ next unless k.start_with?(prefix)
67
+ key = k.delete_prefix(prefix).downcase
68
+ config[klass.plugin_name] ||= {}
69
+ config[klass.plugin_name][key] = parse_env_value(v)
70
+ end
71
+ end
72
+ # Layer 3: CLI overrides (highest priority)
73
+ cli_overrides.each { |k, v| config[k] = (config[k] || {}).merge(v) }
74
+ config
75
+ end
76
+
77
+ def parse_env_value(v)
78
+ case v.downcase
79
+ when "true" then true
80
+ when "false" then false
81
+ else v
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module MarkdownServer
6
+ module Plugins
7
+ module Citations
8
+ DEFAULT_VERSION = "CEB"
9
+
10
+ # Maps every recognised abbreviation to its BibleGateway canonical book name.
11
+ # Keys are sorted longest-first when building the regex so longer forms win.
12
+ BIBLE_BOOK_MAP = {
13
+ # -- Old Testament --
14
+ "Genesis"=>"Genesis","Gen."=>"Genesis","Gen"=>"Genesis","Gn"=>"Genesis",
15
+ "Exodus"=>"Exodus","Ex."=>"Exodus","Exo"=>"Exodus","Ex"=>"Exodus",
16
+ "Leviticus"=>"Leviticus","Lev."=>"Leviticus","Lev"=>"Leviticus",
17
+ "Numbers"=>"Numbers","Num."=>"Numbers","Num"=>"Numbers","Nu."=>"Numbers","Nu"=>"Numbers",
18
+ "Deuteronomy"=>"Deuteronomy","Deut."=>"Deuteronomy","Deut"=>"Deuteronomy",
19
+ "Dt."=>"Deuteronomy","Dt"=>"Deuteronomy",
20
+ "Joshua"=>"Joshua","Josh."=>"Joshua","Josh"=>"Joshua",
21
+ "Judges"=>"Judges","Judg."=>"Judges","Judg"=>"Judges",
22
+ "Ruth"=>"Ruth",
23
+ "1 Samuel"=>"1 Samuel","1Samuel"=>"1 Samuel","1 Sam."=>"1 Samuel","1 Sam"=>"1 Samuel","1Sam."=>"1 Samuel",
24
+ "2 Samuel"=>"2 Samuel","2Samuel"=>"2 Samuel","2 Sam."=>"2 Samuel","2 Sam"=>"2 Samuel","2Sam."=>"2 Samuel","2Sa."=>"2 Samuel",
25
+ "1 Kings"=>"1 Kings","1Kings"=>"1 Kings","1 Ki."=>"1 Kings","1Ki."=>"1 Kings","1 Ki"=>"1 Kings","1Ki"=>"1 Kings",
26
+ "2 Kings"=>"2 Kings","2Kings"=>"2 Kings","2 Ki."=>"2 Kings","2Ki."=>"2 Kings","2 Ki"=>"2 Kings","2Ki"=>"2 Kings",
27
+ "1 Chronicles"=>"1 Chronicles","1 Chr."=>"1 Chronicles","1Chr."=>"1 Chronicles","1Ch"=>"1 Chronicles",
28
+ "2 Chronicles"=>"2 Chronicles","2 Chron."=>"2 Chronicles","2Chron."=>"2 Chronicles","2 Chr."=>"2 Chronicles","2Chr."=>"2 Chronicles","2 Ch"=>"2 Chronicles",
29
+ "Ezra"=>"Ezra","Nehemiah"=>"Nehemiah","Neh."=>"Nehemiah","Neh"=>"Nehemiah",
30
+ "Esther"=>"Esther","Esth."=>"Esther","Esth"=>"Esther",
31
+ "Job"=>"Job",
32
+ "Psalms"=>"Psalms","Psalm"=>"Psalms","Psa."=>"Psalms","Psa"=>"Psalms","Ps."=>"Psalms","Ps"=>"Psalms",
33
+ "Proverbs"=>"Proverbs","Prov."=>"Proverbs","Prov"=>"Proverbs","Pr"=>"Proverbs",
34
+ "Ecclesiastes"=>"Ecclesiastes","Eccl."=>"Ecclesiastes","Eccl"=>"Ecclesiastes","Ecc."=>"Ecclesiastes",
35
+ "Song of Songs"=>"Song of Songs","Song"=>"Song of Songs",
36
+ "Isaiah"=>"Isaiah","Isa."=>"Isaiah","Isa"=>"Isaiah","Is"=>"Isaiah",
37
+ "Jeremiah"=>"Jeremiah","Jer."=>"Jeremiah","Jer"=>"Jeremiah","Je"=>"Jeremiah",
38
+ "Lamentations"=>"Lamentations","Lam."=>"Lamentations","Lam"=>"Lamentations","La"=>"Lamentations",
39
+ "Ezekiel"=>"Ezekiel","Ezek."=>"Ezekiel","Ezek"=>"Ezekiel",
40
+ "Daniel"=>"Daniel","Dan."=>"Daniel","Dan"=>"Daniel",
41
+ "Hosea"=>"Hosea","Hos."=>"Hosea","Hos"=>"Hosea",
42
+ "Joel"=>"Joel","Amos"=>"Amos",
43
+ "Obadiah"=>"Obadiah","Jonah"=>"Jonah","Jon."=>"Jonah","Jon"=>"Jonah",
44
+ "Micah"=>"Micah","Mic."=>"Micah","Mic"=>"Micah",
45
+ "Nahum"=>"Nahum","Nah."=>"Nahum","Nah"=>"Nahum",
46
+ "Habakkuk"=>"Habakkuk","Hab."=>"Habakkuk","Hab"=>"Habakkuk",
47
+ "Haggai"=>"Haggai",
48
+ "Zechariah"=>"Zechariah","Zech."=>"Zechariah","Zech"=>"Zechariah","Zec"=>"Zechariah","Zc"=>"Zechariah",
49
+ "Zephaniah"=>"Zephaniah","Zeph."=>"Zephaniah","Zeph"=>"Zephaniah","Zep."=>"Zephaniah","Zep"=>"Zephaniah",
50
+ "Malachi"=>"Malachi","Mal."=>"Malachi","Mal"=>"Malachi",
51
+ # -- New Testament --
52
+ "Matthew"=>"Matthew","Matt."=>"Matthew","Matt"=>"Matthew","Mt."=>"Matthew","Mt"=>"Matthew",
53
+ "Mark"=>"Mark","Mk."=>"Mark","Mk"=>"Mark",
54
+ "Luke"=>"Luke","Lk."=>"Luke","Lk"=>"Luke",
55
+ "John"=>"John","Jn."=>"John","Jn"=>"John","Jo"=>"John",
56
+ "Acts"=>"Acts","Ac."=>"Acts",
57
+ "Romans"=>"Romans","Rom."=>"Romans","Rom"=>"Romans","Ro"=>"Romans",
58
+ "1 Corinthians"=>"1 Corinthians","1Corinthians"=>"1 Corinthians","1 Cor."=>"1 Corinthians","1Cor."=>"1 Corinthians","1 Cor"=>"1 Corinthians","1Cor"=>"1 Corinthians","1Co."=>"1 Corinthians","1Co"=>"1 Corinthians",
59
+ "2 Corinthians"=>"2 Corinthians","2Corinthians"=>"2 Corinthians","2 Cor."=>"2 Corinthians","2Cor."=>"2 Corinthians","2 Cor"=>"2 Corinthians","2Co."=>"2 Corinthians","2Co"=>"2 Corinthians",
60
+ "Galatians"=>"Galatians","Gal."=>"Galatians","Gal"=>"Galatians",
61
+ "Ephesians"=>"Ephesians","Eph."=>"Ephesians","Eph"=>"Ephesians",
62
+ "Philippians"=>"Philippians","Phil."=>"Philippians","Phil"=>"Philippians",
63
+ "Colossians"=>"Colossians","Col."=>"Colossians","Col"=>"Colossians",
64
+ "1 Thessalonians"=>"1 Thessalonians","1 Thess."=>"1 Thessalonians","1Thess."=>"1 Thessalonians","1 Th"=>"1 Thessalonians","1Th"=>"1 Thessalonians",
65
+ "2 Thessalonians"=>"2 Thessalonians","2Thessalonians"=>"2 Thessalonians","2 Thess."=>"2 Thessalonians","2Thess."=>"2 Thessalonians","2 Th"=>"2 Thessalonians","2Th"=>"2 Thessalonians",
66
+ "1 Timothy"=>"1 Timothy","1Timothy"=>"1 Timothy","1 Tim."=>"1 Timothy","1Tim."=>"1 Timothy","1 Tim"=>"1 Timothy","1Tim"=>"1 Timothy","1 Ti."=>"1 Timothy","1Ti."=>"1 Timothy",
67
+ "2 Timothy"=>"2 Timothy","2Timothy"=>"2 Timothy","2 Tim."=>"2 Timothy","2Tim."=>"2 Timothy","2 Tim"=>"2 Timothy","2Tim"=>"2 Timothy","2 Ti."=>"2 Timothy","2Ti."=>"2 Timothy",
68
+ "Titus"=>"Titus","Tit."=>"Titus","Tit"=>"Titus","Ti."=>"Titus",
69
+ "Philemon"=>"Philemon","Philem."=>"Philemon",
70
+ "Hebrews"=>"Hebrews","Hebrew"=>"Hebrews","Heb."=>"Hebrews","Heb"=>"Hebrews","Hb"=>"Hebrews",
71
+ "James"=>"James","Jas."=>"James","Jas"=>"James",
72
+ "1 Peter"=>"1 Peter","1Peter"=>"1 Peter","1 Pet."=>"1 Peter","1Pet."=>"1 Peter","1 Pt."=>"1 Peter","1Pt."=>"1 Peter",
73
+ "2 Peter"=>"2 Peter","2Peter"=>"2 Peter","2 Pet."=>"2 Peter","2Pet."=>"2 Peter","2 Pt."=>"2 Peter","2Pt."=>"2 Peter","2Pe"=>"2 Peter",
74
+ "1 John"=>"1 John","1John"=>"1 John","1 Jn."=>"1 John","1Jn."=>"1 John","1 Jn"=>"1 John","1Jn"=>"1 John",
75
+ "2 John"=>"2 John","2 Jn."=>"2 John","2Jn."=>"2 John",
76
+ "3 John"=>"3 John","3 Jn."=>"3 John","3Jn."=>"3 John",
77
+ "Jude"=>"Jude",
78
+ "Revelation"=>"Revelation","Rev."=>"Revelation","Rev"=>"Revelation",
79
+ # -- Deuterocanonical (present in CEB) --
80
+ "Wisdom of Solomon"=>"Wisdom of Solomon","Wisdom"=>"Wisdom of Solomon","Wis."=>"Wisdom of Solomon",
81
+ "1 Maccabees"=>"1 Maccabees","1Macc."=>"1 Maccabees",
82
+ }.freeze
83
+
84
+ # Sorted longest-first for runtime lookup (same order as the regex alternation).
85
+ BIBLE_BOOK_MAP_SORTED = BIBLE_BOOK_MAP.sort_by { |k, _| -k.length }.freeze
86
+
87
+ def self.biblegateway_url(canonical, verse, version: DEFAULT_VERSION)
88
+ "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{verse}")}&version=#{version}"
89
+ end
90
+
91
+ def self.link_citations(text)
92
+ text.gsub(BIBLE_CITATION_RE) do |m|
93
+ next m if $1 || $2 || $3
94
+ citation = $4
95
+ entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
96
+ next m unless entry
97
+ abbrev, canonical = entry
98
+ verse = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
99
+ yield canonical, verse, citation
100
+ end
101
+ end
102
+
103
+ _book_alts = BIBLE_BOOK_MAP.keys.sort_by(&:length).reverse
104
+ .map { |k| Regexp.escape(k) }.join("|")
105
+ _verse = /\d+[abc]?(?:[–—\-]\d+[abc]?)?(?:,\s*\d+[abc]?(?:[–—\-]\d+[abc]?)?)*(?:ff\.?|f\.)?/
106
+ _citation = "(?:#{_book_alts})\\.?[ \\t]?\\d+(?:[–—\\-]\\d+)?(?::#{_verse.source})?"
107
+
108
+ BIBLE_CITATION_RE = Regexp.new(
109
+ "(`[^`]*?`)" \
110
+ "|(`{3}[\\s\\S]*?`{3})" \
111
+ "|(?<!\\!)(\\[[^\\[\\]]*\\]\\([^)]+\\))" \
112
+ "|(#{_citation})",
113
+ Regexp::MULTILINE
114
+ )
115
+
116
+ HTML_BIBLE_CITATION_RE = Regexp.new(
117
+ "(<script\\b[^>]*>[\\s\\S]*?</script>)" \
118
+ "|(<style\\b[^>]*>[\\s\\S]*?</style>)" \
119
+ "|(<title\\b[^>]*>[\\s\\S]*?</title>)" \
120
+ "|(<select\\b[^>]*>[\\s\\S]*?</select>)" \
121
+ "|(<a\\b[^>]*>[\\s\\S]*?</a>)" \
122
+ "|(<[^>]+>)" \
123
+ "|(#{_citation})",
124
+ Regexp::MULTILINE | Regexp::IGNORECASE
125
+ )
126
+
127
+ def self.link_citations_html(html)
128
+ html.gsub(HTML_BIBLE_CITATION_RE) do |m|
129
+ next m if $1 || $2 || $3 || $4 || $5 || $6
130
+ citation = $7
131
+ entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
132
+ next m unless entry
133
+ abbrev, canonical = entry
134
+ verse = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
135
+ yield canonical, verse, citation
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require_relative "citations"
5
+ require_relative "../../plugin"
6
+
7
+ module MarkdownServer
8
+ module Plugins
9
+ class BibleCitations
10
+ include MarkdownServer::Plugin
11
+
12
+ def self.enabled_by_default? = true
13
+
14
+ def setup(config)
15
+ @version = config.fetch("version", Citations::DEFAULT_VERSION)
16
+ end
17
+
18
+ def transform_markdown(text)
19
+ Citations.link_citations(text) do |canonical, verse, citation|
20
+ "[#{citation}](#{Citations.biblegateway_url(canonical, verse, version: @version)})"
21
+ end
22
+ end
23
+
24
+ def transform_html(html)
25
+ Citations.link_citations_html(html) do |canonical, verse, citation|
26
+ url = Citations.biblegateway_url(canonical, verse, version: @version)
27
+ %(<a href="#{CGI.escapeHTML(url)}">#{CGI.escapeHTML(citation)}</a>)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.5.21"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -1,2 +1,3 @@
1
1
  require_relative "markdown_server/version"
2
+ require_relative "markdown_server/plugin"
2
3
  require_relative "markdown_server/app"
data/views/layout.erb CHANGED
@@ -1647,7 +1647,26 @@
1647
1647
  if (/^https?:\/\//i.test(href)) return false;
1648
1648
  return /\.md([?#]|$)/i.test(href);
1649
1649
  }
1650
+ function isLocalHtml(href) {
1651
+ if (/^https?:\/\//i.test(href)) return false;
1652
+ return /\.html?([?#]|$)/i.test(href);
1653
+ }
1650
1654
  function isExternal(href) { return /^https?:\/\//i.test(href); }
1655
+ var __popupConfig = <%= { localMd: settings.popup_local_md, localHtml: settings.popup_local_html, external: settings.popup_external, externalDomains: settings.popup_external_domains }.to_json %>;
1656
+ function shouldPopup(href) {
1657
+ if (isAnchorOnly(href)) return false;
1658
+ if (isLocalMd(href)) return __popupConfig.localMd;
1659
+ if (isLocalHtml(href)) return __popupConfig.localHtml;
1660
+ if (isExternal(href)) {
1661
+ if (!__popupConfig.external) return false;
1662
+ if (__popupConfig.externalDomains.length === 0) return true;
1663
+ try {
1664
+ var host = new URL(href).hostname;
1665
+ return __popupConfig.externalDomains.some(function(d) { return host === d || host.endsWith('.' + d); });
1666
+ } catch(e) { return false; }
1667
+ }
1668
+ return false;
1669
+ }
1651
1670
 
1652
1671
  function previewPath(href) {
1653
1672
  var resolved = new URL(href, location.href);
@@ -2104,7 +2123,7 @@
2104
2123
  var href = anchor.getAttribute('href');
2105
2124
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
2106
2125
  if (!anchor.closest('.md-content') && !anchor.closest('.frontmatter')) { hidePopup(); return; }
2107
- if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
2126
+ if (!shouldPopup(href)) { hidePopup(); return; }
2108
2127
  e.preventDefault();
2109
2128
  handleLink(anchor, e.clientX, e.clientY);
2110
2129
  });
@@ -2127,7 +2146,7 @@
2127
2146
  var href = anchor.getAttribute('href');
2128
2147
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
2129
2148
  if (!anchor.closest('.md-content') && !anchor.closest('.frontmatter')) { hidePopup(); return; }
2130
- if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
2149
+ if (!shouldPopup(href)) { hidePopup(); return; }
2131
2150
  e.preventDefault();
2132
2151
  var touch = e.changedTouches[0];
2133
2152
  handleLink(anchor, touch.clientX, touch.clientY);
@@ -2147,7 +2166,7 @@
2147
2166
  container.querySelectorAll('a').forEach(function(a) {
2148
2167
  var href = a.getAttribute('href');
2149
2168
  if (!href || isAnchorOnly(href)) return;
2150
- if (!isLocalMd(href) && !isExternal(href)) return;
2169
+ if (!shouldPopup(href)) return;
2151
2170
  a.addEventListener('mouseenter', function(e) {
2152
2171
  clearTimeout(hoverTimer);
2153
2172
  if (popup) return;
@@ -234,7 +234,26 @@
234
234
  if (/^https?:\/\//i.test(href)) return false;
235
235
  return /\.md([?#]|$)/i.test(href);
236
236
  }
237
+ function isLocalHtml(href) {
238
+ if (/^https?:\/\//i.test(href)) return false;
239
+ return /\.html?([?#]|$)/i.test(href);
240
+ }
237
241
  function isExternal(href) { return /^https?:\/\//i.test(href); }
242
+ function shouldPopup(href) {
243
+ var cfg = window.__popupConfig || { localMd: true, localHtml: false, external: true, externalDomains: [] };
244
+ if (isAnchorOnly(href)) return false;
245
+ if (isLocalMd(href)) return cfg.localMd;
246
+ if (isLocalHtml(href)) return cfg.localHtml;
247
+ if (isExternal(href)) {
248
+ if (!cfg.external) return false;
249
+ if (cfg.externalDomains.length === 0) return true;
250
+ try {
251
+ var host = new URL(href).hostname;
252
+ return cfg.externalDomains.some(function(d) { return host === d || host.endsWith('.' + d); });
253
+ } catch(e) { return false; }
254
+ }
255
+ return false;
256
+ }
238
257
 
239
258
  function previewPath(href) {
240
259
  var resolved = new URL(href, location.href);
@@ -671,7 +690,7 @@
671
690
  if (!anchor) { hidePopup(); return; }
672
691
  var href = anchor.getAttribute('href');
673
692
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
674
- if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
693
+ if (!shouldPopup(href)) { hidePopup(); return; }
675
694
  e.preventDefault();
676
695
  handleLink(anchor, e.clientX, e.clientY, false);
677
696
  });
@@ -694,7 +713,7 @@
694
713
  if (!anchor) { hidePopup(); return; }
695
714
  var href = anchor.getAttribute('href');
696
715
  if (!href || isAnchorOnly(href)) { hidePopup(); return; }
697
- if (!isLocalMd(href) && !isExternal(href)) { hidePopup(); return; }
716
+ if (!shouldPopup(href)) { hidePopup(); return; }
698
717
  e.preventDefault();
699
718
  var touch = e.changedTouches[0];
700
719
  handleLink(anchor, touch.clientX, touch.clientY, false);
@@ -710,7 +729,7 @@
710
729
  document.querySelectorAll('a').forEach(function(a) {
711
730
  var href = a.getAttribute('href');
712
731
  if (!href || isAnchorOnly(href)) return;
713
- if (!isLocalMd(href) && !isExternal(href)) return;
732
+ if (!shouldPopup(href)) return;
714
733
 
715
734
  a.addEventListener('mouseenter', function(e) {
716
735
  clearTimeout(hoverTimer);
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.5.21
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn
@@ -105,7 +105,9 @@ files:
105
105
  - bin/markdownr
106
106
  - lib/markdown_server.rb
107
107
  - lib/markdown_server/app.rb
108
- - lib/markdown_server/bible_citations.rb
108
+ - lib/markdown_server/plugin.rb
109
+ - lib/markdown_server/plugins/bible_citations/citations.rb
110
+ - lib/markdown_server/plugins/bible_citations/plugin.rb
109
111
  - lib/markdown_server/version.rb
110
112
  - views/admin_login.erb
111
113
  - views/directory.erb
@@ -1,174 +0,0 @@
1
- # frozen_string_literal: true
2
- require "cgi"
3
- #
4
- # Bible citation auto-linking for Markdown rendering.
5
- #
6
- # Provides two constants used in render_markdown:
7
- #
8
- # BIBLE_BOOK_MAP – hash of abbreviation → BibleGateway canonical name
9
- # BIBLE_CITATION_RE – combined regex that matches inline code, fenced code
10
- # blocks, existing markdown links (to skip), and bare
11
- # citations (to replace with a BibleGateway link).
12
- #
13
- # Usage in render_markdown:
14
- #
15
- # text = text.gsub(BIBLE_CITATION_RE) do |m|
16
- # next m if $1 || $2 || $3 # code span / code fence / existing link
17
- # citation = $4
18
- # canonical = BIBLE_BOOK_MAP.find { |k, _| citation.start_with?(k) }&.last
19
- # next m unless canonical
20
- # rest = citation[canonical.length..].sub(/\A\.?[ \t]?/, "")
21
- # url = "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{rest}")}&version=CEB"
22
- # "[#{citation}](#{url})"
23
- # end
24
- #
25
- # To copy to another project: copy this file and add the gsub block above to
26
- # your markdown rendering method.
27
-
28
- module MarkdownServer
29
- # Maps every recognised abbreviation to its BibleGateway canonical book name.
30
- # Keys are sorted longest-first when building the regex so longer forms win.
31
- BIBLE_BOOK_MAP = {
32
- # ── Old Testament ────────────────────────────────────────────────────────
33
- "Genesis"=>"Genesis","Gen."=>"Genesis","Gen"=>"Genesis","Gn"=>"Genesis",
34
- "Exodus"=>"Exodus","Ex."=>"Exodus","Exo"=>"Exodus","Ex"=>"Exodus",
35
- "Leviticus"=>"Leviticus","Lev."=>"Leviticus","Lev"=>"Leviticus",
36
- "Numbers"=>"Numbers","Num."=>"Numbers","Num"=>"Numbers","Nu."=>"Numbers","Nu"=>"Numbers",
37
- "Deuteronomy"=>"Deuteronomy","Deut."=>"Deuteronomy","Deut"=>"Deuteronomy",
38
- "Dt."=>"Deuteronomy","Dt"=>"Deuteronomy",
39
- "Joshua"=>"Joshua","Josh."=>"Joshua","Josh"=>"Joshua",
40
- "Judges"=>"Judges","Judg."=>"Judges","Judg"=>"Judges",
41
- "Ruth"=>"Ruth",
42
- "1 Samuel"=>"1 Samuel","1Samuel"=>"1 Samuel","1 Sam."=>"1 Samuel","1 Sam"=>"1 Samuel","1Sam."=>"1 Samuel",
43
- "2 Samuel"=>"2 Samuel","2Samuel"=>"2 Samuel","2 Sam."=>"2 Samuel","2 Sam"=>"2 Samuel","2Sam."=>"2 Samuel","2Sa."=>"2 Samuel",
44
- "1 Kings"=>"1 Kings","1Kings"=>"1 Kings","1 Ki."=>"1 Kings","1Ki."=>"1 Kings","1 Ki"=>"1 Kings","1Ki"=>"1 Kings",
45
- "2 Kings"=>"2 Kings","2Kings"=>"2 Kings","2 Ki."=>"2 Kings","2Ki."=>"2 Kings","2 Ki"=>"2 Kings","2Ki"=>"2 Kings",
46
- "1 Chronicles"=>"1 Chronicles","1 Chr."=>"1 Chronicles","1Chr."=>"1 Chronicles","1Ch"=>"1 Chronicles",
47
- "2 Chronicles"=>"2 Chronicles","2 Chron."=>"2 Chronicles","2Chron."=>"2 Chronicles","2 Chr."=>"2 Chronicles","2Chr."=>"2 Chronicles","2 Ch"=>"2 Chronicles",
48
- "Ezra"=>"Ezra","Nehemiah"=>"Nehemiah","Neh."=>"Nehemiah","Neh"=>"Nehemiah",
49
- "Esther"=>"Esther","Esth."=>"Esther","Esth"=>"Esther",
50
- "Job"=>"Job",
51
- "Psalms"=>"Psalms","Psalm"=>"Psalms","Psa."=>"Psalms","Psa"=>"Psalms","Ps."=>"Psalms","Ps"=>"Psalms",
52
- "Proverbs"=>"Proverbs","Prov."=>"Proverbs","Prov"=>"Proverbs","Pr"=>"Proverbs",
53
- "Ecclesiastes"=>"Ecclesiastes","Eccl."=>"Ecclesiastes","Eccl"=>"Ecclesiastes","Ecc."=>"Ecclesiastes",
54
- "Song of Songs"=>"Song of Songs","Song"=>"Song of Songs",
55
- "Isaiah"=>"Isaiah","Isa."=>"Isaiah","Isa"=>"Isaiah","Is"=>"Isaiah",
56
- "Jeremiah"=>"Jeremiah","Jer."=>"Jeremiah","Jer"=>"Jeremiah","Je"=>"Jeremiah",
57
- "Lamentations"=>"Lamentations","Lam."=>"Lamentations","Lam"=>"Lamentations","La"=>"Lamentations",
58
- "Ezekiel"=>"Ezekiel","Ezek."=>"Ezekiel","Ezek"=>"Ezekiel",
59
- "Daniel"=>"Daniel","Dan."=>"Daniel","Dan"=>"Daniel",
60
- "Hosea"=>"Hosea","Hos."=>"Hosea","Hos"=>"Hosea",
61
- "Joel"=>"Joel","Amos"=>"Amos",
62
- "Obadiah"=>"Obadiah","Jonah"=>"Jonah","Jon."=>"Jonah","Jon"=>"Jonah",
63
- "Micah"=>"Micah","Mic."=>"Micah","Mic"=>"Micah",
64
- "Nahum"=>"Nahum","Nah."=>"Nahum","Nah"=>"Nahum",
65
- "Habakkuk"=>"Habakkuk","Hab."=>"Habakkuk","Hab"=>"Habakkuk",
66
- "Haggai"=>"Haggai",
67
- "Zechariah"=>"Zechariah","Zech."=>"Zechariah","Zech"=>"Zechariah","Zec"=>"Zechariah","Zc"=>"Zechariah",
68
- "Zephaniah"=>"Zephaniah","Zeph."=>"Zephaniah","Zeph"=>"Zephaniah","Zep."=>"Zephaniah","Zep"=>"Zephaniah",
69
- "Malachi"=>"Malachi","Mal."=>"Malachi","Mal"=>"Malachi",
70
- # ── New Testament ────────────────────────────────────────────────────────
71
- "Matthew"=>"Matthew","Matt."=>"Matthew","Matt"=>"Matthew","Mt."=>"Matthew","Mt"=>"Matthew",
72
- "Mark"=>"Mark","Mk."=>"Mark","Mk"=>"Mark",
73
- "Luke"=>"Luke","Lk."=>"Luke","Lk"=>"Luke",
74
- "John"=>"John","Jn."=>"John","Jn"=>"John","Jo"=>"John",
75
- "Acts"=>"Acts","Ac."=>"Acts",
76
- "Romans"=>"Romans","Rom."=>"Romans","Rom"=>"Romans","Ro"=>"Romans",
77
- "1 Corinthians"=>"1 Corinthians","1Corinthians"=>"1 Corinthians","1 Cor."=>"1 Corinthians","1Cor."=>"1 Corinthians","1 Cor"=>"1 Corinthians","1Cor"=>"1 Corinthians","1Co."=>"1 Corinthians","1Co"=>"1 Corinthians",
78
- "2 Corinthians"=>"2 Corinthians","2Corinthians"=>"2 Corinthians","2 Cor."=>"2 Corinthians","2Cor."=>"2 Corinthians","2 Cor"=>"2 Corinthians","2Co."=>"2 Corinthians","2Co"=>"2 Corinthians",
79
- "Galatians"=>"Galatians","Gal."=>"Galatians","Gal"=>"Galatians",
80
- "Ephesians"=>"Ephesians","Eph."=>"Ephesians","Eph"=>"Ephesians",
81
- "Philippians"=>"Philippians","Phil."=>"Philippians","Phil"=>"Philippians",
82
- "Colossians"=>"Colossians","Col."=>"Colossians","Col"=>"Colossians",
83
- "1 Thessalonians"=>"1 Thessalonians","1 Thess."=>"1 Thessalonians","1Thess."=>"1 Thessalonians","1 Th"=>"1 Thessalonians","1Th"=>"1 Thessalonians",
84
- "2 Thessalonians"=>"2 Thessalonians","2Thessalonians"=>"2 Thessalonians","2 Thess."=>"2 Thessalonians","2Thess."=>"2 Thessalonians","2 Th"=>"2 Thessalonians","2Th"=>"2 Thessalonians",
85
- "1 Timothy"=>"1 Timothy","1Timothy"=>"1 Timothy","1 Tim."=>"1 Timothy","1Tim."=>"1 Timothy","1 Tim"=>"1 Timothy","1Tim"=>"1 Timothy","1 Ti."=>"1 Timothy","1Ti."=>"1 Timothy",
86
- "2 Timothy"=>"2 Timothy","2Timothy"=>"2 Timothy","2 Tim."=>"2 Timothy","2Tim."=>"2 Timothy","2 Tim"=>"2 Timothy","2Tim"=>"2 Timothy","2 Ti."=>"2 Timothy","2Ti."=>"2 Timothy",
87
- "Titus"=>"Titus","Tit."=>"Titus","Tit"=>"Titus","Ti."=>"Titus",
88
- "Philemon"=>"Philemon","Philem."=>"Philemon",
89
- "Hebrews"=>"Hebrews","Hebrew"=>"Hebrews","Heb."=>"Hebrews","Heb"=>"Hebrews","Hb"=>"Hebrews",
90
- "James"=>"James","Jas."=>"James","Jas"=>"James",
91
- "1 Peter"=>"1 Peter","1Peter"=>"1 Peter","1 Pet."=>"1 Peter","1Pet."=>"1 Peter","1 Pt."=>"1 Peter","1Pt."=>"1 Peter",
92
- "2 Peter"=>"2 Peter","2Peter"=>"2 Peter","2 Pet."=>"2 Peter","2Pet."=>"2 Peter","2 Pt."=>"2 Peter","2Pt."=>"2 Peter","2Pe"=>"2 Peter",
93
- "1 John"=>"1 John","1John"=>"1 John","1 Jn."=>"1 John","1Jn."=>"1 John","1 Jn"=>"1 John","1Jn"=>"1 John",
94
- "2 John"=>"2 John","2 Jn."=>"2 John","2Jn."=>"2 John",
95
- "3 John"=>"3 John","3 Jn."=>"3 John","3Jn."=>"3 John",
96
- "Jude"=>"Jude",
97
- "Revelation"=>"Revelation","Rev."=>"Revelation","Rev"=>"Revelation",
98
- # ── Deuterocanonical (present in CEB) ───────────────────────────────────
99
- "Wisdom of Solomon"=>"Wisdom of Solomon","Wisdom"=>"Wisdom of Solomon","Wis."=>"Wisdom of Solomon",
100
- "1 Maccabees"=>"1 Maccabees","1Macc."=>"1 Maccabees",
101
- }.freeze
102
-
103
- # Sorted longest-first for runtime lookup (same order as the regex alternation).
104
- BIBLE_BOOK_MAP_SORTED = BIBLE_BOOK_MAP.sort_by { |k, _| -k.length }.freeze
105
-
106
- BIBLEGATEWAY_VERSION = "CEB".freeze
107
-
108
- def self.biblegateway_url(canonical, verse)
109
- "https://www.biblegateway.com/passage/?search=#{CGI.escape("#{canonical} #{verse}")}&version=#{BIBLEGATEWAY_VERSION}"
110
- end
111
-
112
- # Scans +text+ for bare Bible verse citations and yields each one as
113
- # (canonical_book, verse, raw_citation). Returns the transformed string
114
- # with each citation replaced by whatever the block returns. Code spans,
115
- # fenced code blocks, and citations already inside a markdown link are
116
- # passed through unchanged.
117
- #
118
- # MarkdownServer.link_citations("See Rom 12:1.") { |c, v, _| "[#{c} #{v}]" }
119
- # # => "See [Romans 12:1]."
120
- def self.link_citations(text)
121
- text.gsub(BIBLE_CITATION_RE) do |m|
122
- next m if $1 || $2 || $3 # code span / code fence / existing link
123
- citation = $4
124
- entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
125
- next m unless entry
126
- abbrev, canonical = entry
127
- verse = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
128
- yield canonical, verse, citation
129
- end
130
- end
131
-
132
- _book_alts = BIBLE_BOOK_MAP.keys.sort_by(&:length).reverse
133
- .map { |k| Regexp.escape(k) }.join("|")
134
- _verse = /\d+[abc]?(?:[–—\-]\d+[abc]?)?(?:,\s*\d+[abc]?(?:[–—\-]\d+[abc]?)?)*(?:ff\.?|f\.)?/
135
- _citation = "(?:#{_book_alts})\\.?[ \\t]?\\d+(?::#{_verse.source})?"
136
-
137
- # Combined regex. Four alternatives — only the last (group 4) is replaced:
138
- # 1. inline code span → return verbatim
139
- # 2. fenced code block → return verbatim
140
- # 3. existing md link → return verbatim
141
- # 4. bare citation → replace with BibleGateway markdown link
142
- BIBLE_CITATION_RE = Regexp.new(
143
- "(`[^`]*?`)" \
144
- "|(`{3}[\\s\\S]*?`{3})" \
145
- "|(?<!\\!)(\\[[^\\[\\]]*\\]\\([^)]+\\))" \
146
- "|(#{_citation})",
147
- Regexp::MULTILINE
148
- )
149
-
150
- # HTML-aware version of BIBLE_CITATION_RE. Skips <script>, <style>, existing
151
- # <a> elements, and all other HTML tags. Group 5 captures bare citations.
152
- HTML_BIBLE_CITATION_RE = Regexp.new(
153
- "(<script\\b[^>]*>[\\s\\S]*?</script>)" \
154
- "|(<style\\b[^>]*>[\\s\\S]*?</style>)" \
155
- "|(<a\\b[^>]*>[\\s\\S]*?</a>)" \
156
- "|(<[^>]+>)" \
157
- "|(#{_citation})",
158
- Regexp::MULTILINE | Regexp::IGNORECASE
159
- )
160
-
161
- # Like link_citations but operates on raw HTML. Skips tag content, script/style
162
- # blocks, and existing anchor elements; yields bare citations in text nodes.
163
- def self.link_citations_html(html)
164
- html.gsub(HTML_BIBLE_CITATION_RE) do |m|
165
- next m if $1 || $2 || $3 || $4 # skip tags / scripts / styles / anchors
166
- citation = $5
167
- entry = BIBLE_BOOK_MAP_SORTED.find { |k, _| citation.start_with?(k) }
168
- next m unless entry
169
- abbrev, canonical = entry
170
- verse = citation[abbrev.length..].sub(/\A\.?[ \t]?/, "")
171
- yield canonical, verse, citation
172
- end
173
- end
174
- end