markdownr 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/Dockerfile.markdownr +1 -1
- data/bin/markdownr +79 -0
- data/bin/markdownr-servers.yaml +39 -0
- data/bin/start-claude +2 -0
- data/lib/markdown_server/app.rb +953 -107
- data/lib/markdown_server/assets/editor-loader.js +362 -0
- data/lib/markdown_server/csv_browser/addon_registry.rb +137 -0
- data/lib/markdown_server/csv_browser/config_loader.rb +231 -0
- data/lib/markdown_server/csv_browser/row_context.rb +146 -0
- data/lib/markdown_server/csv_browser/table_reader.rb +259 -0
- data/lib/markdown_server/helpers/admin_helpers.rb +25 -1
- data/lib/markdown_server/helpers/formatting_helpers.rb +3 -1
- data/lib/markdown_server/helpers/markdown_helpers.rb +132 -5
- data/lib/markdown_server/helpers/path_helpers.rb +56 -7
- data/lib/markdown_server/helpers/search_helpers.rb +31 -3
- data/lib/markdown_server/permitted_bases.rb +13 -0
- data/lib/markdown_server/plugin.rb +11 -0
- data/lib/markdown_server/plugins/bible_citations/citations.rb +4 -4
- data/lib/markdown_server/unhide.rb +114 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/browser.erb +5794 -0
- data/views/layout.erb +124 -20
- data/views/popup_assets.erb +52 -26
- metadata +40 -2
|
@@ -73,7 +73,7 @@ module MarkdownServer
|
|
|
73
73
|
%(<sup id="fnref:#{name}" role="doc-noteref"><a href="#fn:#{name}" class="footnote" rel="footnote">#{h(name)}</a></sup>)
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
|
-
html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
76
|
+
html = html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
77
77
|
full_match = $&
|
|
78
78
|
name = $1
|
|
79
79
|
if name =~ /\A\d+\z/
|
|
@@ -82,6 +82,107 @@ module MarkdownServer
|
|
|
82
82
|
%(<li id="fn:#{name}"><p><strong>#{h(name)}:</strong> )
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
|
+
|
|
86
|
+
transform_callouts(html)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Obsidian-style callouts: `> [!TYPE] Optional Title` at the start of a
|
|
90
|
+
# blockquote becomes a styled .callout panel. `[!TYPE]+` makes it a
|
|
91
|
+
# foldable <details open>; `[!TYPE]-` makes it foldable and collapsed.
|
|
92
|
+
CALLOUT_TYPES = {
|
|
93
|
+
"note" => { label: "Note", color: "#448aff" },
|
|
94
|
+
"abstract" => { label: "Abstract", color: "#00b0ff" },
|
|
95
|
+
"summary" => { label: "Summary", color: "#00b0ff" },
|
|
96
|
+
"tldr" => { label: "TL;DR", color: "#00b0ff" },
|
|
97
|
+
"info" => { label: "Info", color: "#00b8d4" },
|
|
98
|
+
"todo" => { label: "Todo", color: "#00b8d4" },
|
|
99
|
+
"tip" => { label: "Tip", color: "#00bfa5" },
|
|
100
|
+
"hint" => { label: "Hint", color: "#00bfa5" },
|
|
101
|
+
"important"=> { label: "Important",color: "#00bfa5" },
|
|
102
|
+
"success" => { label: "Success", color: "#00c853" },
|
|
103
|
+
"check" => { label: "Check", color: "#00c853" },
|
|
104
|
+
"done" => { label: "Done", color: "#00c853" },
|
|
105
|
+
"question" => { label: "Question", color: "#64dd17" },
|
|
106
|
+
"help" => { label: "Help", color: "#64dd17" },
|
|
107
|
+
"faq" => { label: "FAQ", color: "#64dd17" },
|
|
108
|
+
"warning" => { label: "Warning", color: "#ff9100" },
|
|
109
|
+
"caution" => { label: "Caution", color: "#ff9100" },
|
|
110
|
+
"attention"=> { label: "Attention",color: "#ff9100" },
|
|
111
|
+
"failure" => { label: "Failure", color: "#ff5252" },
|
|
112
|
+
"fail" => { label: "Fail", color: "#ff5252" },
|
|
113
|
+
"missing" => { label: "Missing", color: "#ff5252" },
|
|
114
|
+
"danger" => { label: "Danger", color: "#ff1744" },
|
|
115
|
+
"error" => { label: "Error", color: "#ff1744" },
|
|
116
|
+
"bug" => { label: "Bug", color: "#f50057" },
|
|
117
|
+
"example" => { label: "Example", color: "#7c4dff" },
|
|
118
|
+
"quote" => { label: "Quote", color: "#9e9e9e" },
|
|
119
|
+
"cite" => { label: "Cite", color: "#9e9e9e" }
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
CALLOUT_ICONS = {
|
|
123
|
+
"note" => '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
124
|
+
"abstract" => '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h6"/>',
|
|
125
|
+
"info" => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
|
126
|
+
"todo" => '<circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10"/>',
|
|
127
|
+
"tip" => '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
|
128
|
+
"success" => '<polyline points="20 6 9 17 4 12"/>',
|
|
129
|
+
"question" => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
130
|
+
"warning" => '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
131
|
+
"failure" => '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
|
132
|
+
"danger" => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
|
133
|
+
"bug" => '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="M19 7l-3 2M5 7l3 2M19 13h-3M8 13H5M19 19l-3-2M5 19l3-2M9 2l1 2M15 2l-1 2"/>',
|
|
134
|
+
"example" => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
|
135
|
+
"quote" => '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>'
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
CALLOUT_ICON_ALIASES = {
|
|
139
|
+
"summary" => "abstract", "tldr" => "abstract",
|
|
140
|
+
"hint" => "tip", "important" => "tip",
|
|
141
|
+
"check" => "success", "done" => "success",
|
|
142
|
+
"help" => "question", "faq" => "question",
|
|
143
|
+
"caution" => "warning", "attention" => "warning",
|
|
144
|
+
"fail" => "failure", "missing" => "failure",
|
|
145
|
+
"error" => "danger",
|
|
146
|
+
"cite" => "quote"
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
def callout_meta(type)
|
|
150
|
+
CALLOUT_TYPES[type] || { label: type.capitalize, color: "#888888" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def callout_icon_svg(type)
|
|
154
|
+
key = CALLOUT_ICON_ALIASES[type] || type
|
|
155
|
+
body = CALLOUT_ICONS[key] || CALLOUT_ICONS["note"]
|
|
156
|
+
%(<svg class="callout-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">#{body}</svg>)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def transform_callouts(html)
|
|
160
|
+
html.gsub(
|
|
161
|
+
%r{<blockquote>\s*<p>\[!(\w+)\]([+-])?[ \t]*([^\n<]*?)[ \t]*(?:<br\s*/?>\n?|\n)?(.*?)</p>(.*?)</blockquote>}m
|
|
162
|
+
) do
|
|
163
|
+
type = $1.downcase
|
|
164
|
+
fold = $2
|
|
165
|
+
custom_title = $3.to_s.strip
|
|
166
|
+
first_p_rest = $4.to_s.strip
|
|
167
|
+
rest = $5.to_s.strip
|
|
168
|
+
|
|
169
|
+
meta = callout_meta(type)
|
|
170
|
+
title = custom_title.empty? ? meta[:label] : custom_title
|
|
171
|
+
|
|
172
|
+
body = +""
|
|
173
|
+
body << "<p>#{first_p_rest}</p>" unless first_p_rest.empty?
|
|
174
|
+
body << rest
|
|
175
|
+
|
|
176
|
+
icon = callout_icon_svg(type)
|
|
177
|
+
title_html = %(#{icon}<span class="callout-title-inner">#{h(title)}</span>)
|
|
178
|
+
|
|
179
|
+
if fold
|
|
180
|
+
open_attr = fold == "+" ? " open" : ""
|
|
181
|
+
%(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><details#{open_attr}><summary class="callout-title">#{title_html}</summary><div class="callout-content">#{body}</div></details></div>)
|
|
182
|
+
else
|
|
183
|
+
%(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><div class="callout-title">#{title_html}</div><div class="callout-content">#{body}</div></div>)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
85
186
|
end
|
|
86
187
|
|
|
87
188
|
def resolve_wiki_link(name)
|
|
@@ -101,8 +202,7 @@ module MarkdownServer
|
|
|
101
202
|
real = File.realpath(path) rescue next
|
|
102
203
|
next unless real.start_with?(base)
|
|
103
204
|
relative = real.sub("#{base}/", "")
|
|
104
|
-
|
|
105
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
205
|
+
next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
|
|
106
206
|
if File.basename(real) == filename
|
|
107
207
|
local_exact = relative
|
|
108
208
|
break
|
|
@@ -124,8 +224,7 @@ module MarkdownServer
|
|
|
124
224
|
real = File.realpath(path) rescue next
|
|
125
225
|
next unless real.start_with?(base)
|
|
126
226
|
relative = real.sub("#{base}/", "")
|
|
127
|
-
|
|
128
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
227
|
+
next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
|
|
129
228
|
if File.basename(real) == filename
|
|
130
229
|
exact_match ||= relative
|
|
131
230
|
else
|
|
@@ -203,6 +302,34 @@ module MarkdownServer
|
|
|
203
302
|
formatter.format(lexer.lex(code))
|
|
204
303
|
end
|
|
205
304
|
|
|
305
|
+
def detect_source_language(real_path, content)
|
|
306
|
+
ext = File.extname(real_path).downcase
|
|
307
|
+
by_ext = case ext
|
|
308
|
+
when ".py" then "python"
|
|
309
|
+
when ".rb" then "ruby"
|
|
310
|
+
when ".csv" then "text"
|
|
311
|
+
when ".sh" then "bash"
|
|
312
|
+
when ".yaml", ".yml" then "yaml"
|
|
313
|
+
when ".erb" then "html"
|
|
314
|
+
when ".html" then "html"
|
|
315
|
+
when ".js" then "javascript"
|
|
316
|
+
when ".css" then "css"
|
|
317
|
+
when ".json" then "json"
|
|
318
|
+
end
|
|
319
|
+
return by_ext if by_ext
|
|
320
|
+
|
|
321
|
+
if content.is_a?(String) && content.start_with?("#!")
|
|
322
|
+
shebang = content.lines.first.to_s
|
|
323
|
+
return "ruby" if shebang.match?(/\bruby\b/)
|
|
324
|
+
return "python" if shebang.match?(/\bpython[\d.]*\b/)
|
|
325
|
+
return "bash" if shebang.match?(/\b(bash|zsh|dash|ksh|sh)\b/)
|
|
326
|
+
return "javascript" if shebang.match?(/\bnode\b/)
|
|
327
|
+
return "perl" if shebang.match?(/\bperl\b/)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
"text"
|
|
331
|
+
end
|
|
332
|
+
|
|
206
333
|
def extract_toc(html)
|
|
207
334
|
headings = []
|
|
208
335
|
html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
|
|
@@ -13,6 +13,27 @@ module MarkdownServer
|
|
|
13
13
|
URI.encode_www_form_component(str).gsub("+", "%20")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Returns the permitted base (root realpath or a followed-link target realpath)
|
|
17
|
+
# that contains `real`, or nil if no permitted base contains it.
|
|
18
|
+
def permitted_base_for(real)
|
|
19
|
+
MarkdownServer::PermittedBases.base_for(real, permitted_bases)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def permitted_bases
|
|
23
|
+
[File.realpath(root_dir), *Array(settings.followed_links)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns true if `real` is under a permitted base AND every restricted
|
|
27
|
+
# segment of the path under that base is admitted by the configured
|
|
28
|
+
# unhide rules (per Unhide.visible? algorithm).
|
|
29
|
+
def permitted_path?(real)
|
|
30
|
+
base = permitted_base_for(real)
|
|
31
|
+
return false unless base
|
|
32
|
+
return true if real == base
|
|
33
|
+
rel = real.sub("#{base}/", "")
|
|
34
|
+
MarkdownServer::Unhide.visible?(rel.split("/"), Array(settings.unhide_rules))
|
|
35
|
+
end
|
|
36
|
+
|
|
16
37
|
def safe_path(requested)
|
|
17
38
|
base = File.realpath(root_dir)
|
|
18
39
|
full = File.join(base, requested)
|
|
@@ -23,17 +44,45 @@ module MarkdownServer
|
|
|
23
44
|
halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
|
|
24
45
|
end
|
|
25
46
|
|
|
26
|
-
unless
|
|
27
|
-
|
|
47
|
+
halt 403, erb(:layout) { "<h1>Forbidden</h1>" } unless permitted_path?(real)
|
|
48
|
+
real
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Name-gate check for an entry in a directory listing.
|
|
52
|
+
#
|
|
53
|
+
# Boundary gate (against served root) is NOT applied here so non-followed
|
|
54
|
+
# external non-dot symlinks still appear in listings — matching today's
|
|
55
|
+
# UX; clicking them produces a 403 via safe_path/permitted_path?.
|
|
56
|
+
#
|
|
57
|
+
# When the name gate admits a normally-restricted entry (dotfile or
|
|
58
|
+
# EXCLUDED) that is also a symlink, additionally require the symlink's
|
|
59
|
+
# realpath to be explicitly listed in --follow-link. Otherwise the
|
|
60
|
+
# unhide rule does not surface it. Keeps `unhide` (visibility by name)
|
|
61
|
+
# and `--follow-link` (which symlinks may be entered) as orthogonal
|
|
62
|
+
# opt-ins; prevents internal-aliased dotfile symlinks like
|
|
63
|
+
# `.claude → claude` from showing up as duplicates of their targets.
|
|
64
|
+
def entry_admitted?(parent_real, parent_relative_str, entry_name)
|
|
65
|
+
rules = Array(settings.unhide_rules)
|
|
66
|
+
parent_segs = parent_relative_str.to_s.empty? ? [] : parent_relative_str.split("/")
|
|
67
|
+
|
|
68
|
+
mode = :open
|
|
69
|
+
parent_segs.each_with_index do |seg, i|
|
|
70
|
+
ok, mode = MarkdownServer::Unhide.step(mode, parent_segs, i, seg, rules)
|
|
71
|
+
return false unless ok
|
|
28
72
|
end
|
|
29
73
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
74
|
+
ok, _ = MarkdownServer::Unhide.entry_step(mode, parent_segs, entry_name, rules)
|
|
75
|
+
return false unless ok
|
|
76
|
+
|
|
77
|
+
if MarkdownServer::Unhide.restricted?(entry_name)
|
|
78
|
+
full = File.join(parent_real, entry_name)
|
|
79
|
+
if File.symlink?(full)
|
|
80
|
+
real = File.realpath(full) rescue (return false)
|
|
81
|
+
return false unless Array(settings.followed_links).include?(real)
|
|
82
|
+
end
|
|
34
83
|
end
|
|
35
84
|
|
|
36
|
-
|
|
85
|
+
true
|
|
37
86
|
end
|
|
38
87
|
end
|
|
39
88
|
end
|
|
@@ -60,13 +60,41 @@ module MarkdownServer
|
|
|
60
60
|
results
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def walk_directory(dir_path, &block)
|
|
63
|
+
def walk_directory(dir_path, parent_segs = nil, parent_mode = nil, &block)
|
|
64
|
+
rules = Array(settings.unhide_rules)
|
|
65
|
+
bases = permitted_bases
|
|
66
|
+
|
|
67
|
+
if parent_segs.nil?
|
|
68
|
+
base = File.realpath(root_dir)
|
|
69
|
+
real_dir = File.realpath(dir_path) rescue dir_path
|
|
70
|
+
parent_segs = (real_dir == base || !real_dir.start_with?("#{base}/")) ? [] : real_dir.sub("#{base}/", "").split("/")
|
|
71
|
+
parent_mode = :open
|
|
72
|
+
parent_segs.each_with_index do |seg, i|
|
|
73
|
+
ok, parent_mode = MarkdownServer::Unhide.step(parent_mode, parent_segs, i, seg, rules)
|
|
74
|
+
return unless ok
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
64
78
|
Dir.entries(dir_path).sort.each do |entry|
|
|
65
|
-
next if entry
|
|
79
|
+
next if entry == "." || entry == ".."
|
|
80
|
+
|
|
81
|
+
visible, child_mode = MarkdownServer::Unhide.entry_step(parent_mode, parent_segs, entry, rules)
|
|
82
|
+
next unless visible
|
|
83
|
+
|
|
66
84
|
full = File.join(dir_path, entry)
|
|
85
|
+
real = File.realpath(full) rescue next
|
|
86
|
+
next unless MarkdownServer::PermittedBases.base_for(real, bases)
|
|
87
|
+
|
|
88
|
+
# Restricted entries that are symlinks: only descend / index when
|
|
89
|
+
# the symlink's realpath is explicitly in --follow-link. Mirrors
|
|
90
|
+
# entry_admitted? in path_helpers; prevents unhide from
|
|
91
|
+
# double-walking internal-aliased dotfile symlinks.
|
|
92
|
+
if MarkdownServer::Unhide.restricted?(entry) && File.symlink?(full)
|
|
93
|
+
next unless Array(settings.followed_links).include?(real)
|
|
94
|
+
end
|
|
67
95
|
|
|
68
96
|
if File.directory?(full)
|
|
69
|
-
walk_directory(full, &block)
|
|
97
|
+
walk_directory(full, parent_segs + [entry], child_mode, &block)
|
|
70
98
|
elsif File.file?(full)
|
|
71
99
|
ext = File.extname(entry).downcase
|
|
72
100
|
next if BINARY_EXTENSIONS.include?(ext)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module PermittedBases
|
|
3
|
+
# Returns the permitted base (one of `bases`) that contains `real`,
|
|
4
|
+
# or nil if no base contains it. Each base is the realpath of the
|
|
5
|
+
# served root or a --follow-link target.
|
|
6
|
+
def self.base_for(real, bases)
|
|
7
|
+
Array(bases).each do |b|
|
|
8
|
+
return b if real == b || real.start_with?("#{b}/")
|
|
9
|
+
end
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -33,6 +33,10 @@ module MarkdownServer
|
|
|
33
33
|
|
|
34
34
|
# Hook: post-process rendered markdown HTML (runs after render_markdown)
|
|
35
35
|
def post_render(html, meta, app) html end
|
|
36
|
+
|
|
37
|
+
# Hook: claim a file for custom popup rendering in /browser
|
|
38
|
+
# Return { type:, title:, ... } hash or nil to pass
|
|
39
|
+
def browser_render(relative_path, real_path, app) nil end
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
class PluginRegistry
|
|
@@ -53,6 +57,13 @@ module MarkdownServer
|
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
config = resolve_config(root_dir, cli_overrides)
|
|
60
|
+
known_names = registered.map(&:plugin_name)
|
|
61
|
+
config.each do |name, cfg|
|
|
62
|
+
next unless cfg.is_a?(Hash) && cfg["enabled"]
|
|
63
|
+
unless known_names.include?(name)
|
|
64
|
+
$stderr.puts "\n\e[1;33mWarning: unknown plugin '#{name}' (available: #{known_names.join(", ")})\e[0m\n\n"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
56
67
|
registered.filter_map do |klass|
|
|
57
68
|
plugin_config = config.fetch(klass.plugin_name, {})
|
|
58
69
|
enabled = plugin_config.fetch("enabled", klass.enabled_by_default?)
|
|
@@ -90,18 +90,18 @@ module MarkdownServer
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def self.scrip_url(canonical, verse, version: DEFAULT_VERSION)
|
|
93
|
-
# Parse chapter and optional
|
|
93
|
+
# Parse chapter and optional verse range from the verse string
|
|
94
94
|
# verse is e.g. "1:1", "1:1-5", "1", "1-2", "1:1,3,5"
|
|
95
|
-
if verse =~ /\A(\d+)(?::(\d+))?/
|
|
95
|
+
if verse =~ /\A(\d+)(?::(\d+(?:[–—\-]\d+)?))?/
|
|
96
96
|
chapter = format("%03d", $1.to_i)
|
|
97
|
-
|
|
97
|
+
verse_part = $2&.gsub(/[–—]/, "-")
|
|
98
98
|
else
|
|
99
99
|
return biblegateway_url(canonical, verse, version: version)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
encode = ->(s) { URI.encode_www_form_component(s).gsub("+", "%20") }
|
|
103
103
|
url = "https://scrip.risensavior.com/browse/#{encode[version]}/#{encode[canonical]}/#{chapter}.html"
|
|
104
|
-
url += "#v#{
|
|
104
|
+
url += "#v#{verse_part}" if verse_part
|
|
105
105
|
url
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module MarkdownServer
|
|
2
|
+
module Unhide
|
|
3
|
+
Rule = Struct.new(:kind, :segments)
|
|
4
|
+
|
|
5
|
+
class CompileError < StandardError; end
|
|
6
|
+
|
|
7
|
+
def self.compile(raw_entries)
|
|
8
|
+
Array(raw_entries).compact.map { |e| compile_one(e) }.uniq
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.compile_one(raw)
|
|
12
|
+
entry = raw.to_s.strip
|
|
13
|
+
raise CompileError, "unhide entry is empty" if entry.empty?
|
|
14
|
+
|
|
15
|
+
if entry.start_with?("@/")
|
|
16
|
+
path = entry.sub(%r{^@/+}, "")
|
|
17
|
+
raise CompileError, "unhide entry '#{raw}' has no path after '@/'" if path.empty?
|
|
18
|
+
segments = normalize_segments(path)
|
|
19
|
+
raise CompileError, "unhide entry '#{raw}' has no usable segments" if segments.empty?
|
|
20
|
+
Rule.new(:anchored, segments)
|
|
21
|
+
else
|
|
22
|
+
if entry.start_with?("/")
|
|
23
|
+
raise CompileError,
|
|
24
|
+
"unhide entry '#{raw}' has a leading '/'. " \
|
|
25
|
+
"Use '@/...' for project-root-anchored, or omit the slash for any-depth match."
|
|
26
|
+
end
|
|
27
|
+
segments = normalize_segments(entry)
|
|
28
|
+
raise CompileError, "unhide entry '#{raw}' has no usable segments" if segments.empty?
|
|
29
|
+
Rule.new(segments.length == 1 ? :basename : :suffix, segments)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.normalize_segments(path)
|
|
34
|
+
path.split("/").reject(&:empty?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.restricted?(name)
|
|
38
|
+
return false if name.nil? || name.empty?
|
|
39
|
+
name.start_with?(".") || MarkdownServer::EXCLUDED.include?(name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns :leaf if any rule matches segment i as its leaf,
|
|
43
|
+
# :prefix if any rule matches segment i as a navigational prefix,
|
|
44
|
+
# :none otherwise.
|
|
45
|
+
def self.match_at(path_segs, i, rules)
|
|
46
|
+
best = :none
|
|
47
|
+
rules.each do |r|
|
|
48
|
+
case r.kind
|
|
49
|
+
when :basename
|
|
50
|
+
return :leaf if r.segments[0] == path_segs[i]
|
|
51
|
+
when :suffix
|
|
52
|
+
r.segments.length.times do |k|
|
|
53
|
+
next unless i >= k
|
|
54
|
+
next unless path_segs[(i - k)..i] == r.segments[0..k]
|
|
55
|
+
if k + 1 == r.segments.length
|
|
56
|
+
return :leaf
|
|
57
|
+
else
|
|
58
|
+
best = :prefix
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
when :anchored
|
|
62
|
+
if i < r.segments.length && path_segs[0..i] == r.segments[0..i]
|
|
63
|
+
if i + 1 == r.segments.length
|
|
64
|
+
return :leaf
|
|
65
|
+
else
|
|
66
|
+
best = :prefix
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
best
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Cold check: walks segment by segment from root, tracking mode.
|
|
75
|
+
# Returns true iff every segment of path_segments is admitted.
|
|
76
|
+
def self.visible?(path_segments, rules)
|
|
77
|
+
mode = :open
|
|
78
|
+
path_segments.each_with_index do |seg, i|
|
|
79
|
+
visible, mode = step(mode, path_segments, i, seg, rules)
|
|
80
|
+
return false unless visible
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Amortized step for an enumeration. parent_mode and parent_segs are known
|
|
86
|
+
# from the enclosing recursion; this checks one entry. Returns [visible, child_mode].
|
|
87
|
+
def self.entry_step(parent_mode, parent_segs, entry_name, rules)
|
|
88
|
+
path_segs = parent_segs + [entry_name]
|
|
89
|
+
step(parent_mode, path_segs, path_segs.length - 1, entry_name, rules)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.step(mode, path_segs, i, seg, rules)
|
|
93
|
+
restricted = restricted?(seg)
|
|
94
|
+
|
|
95
|
+
if restricted
|
|
96
|
+
case match_at(path_segs, i, rules)
|
|
97
|
+
when :leaf then [true, :open]
|
|
98
|
+
when :prefix then [true, :narrow]
|
|
99
|
+
else [false, nil]
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
if mode == :open
|
|
103
|
+
[true, :open]
|
|
104
|
+
else
|
|
105
|
+
case match_at(path_segs, i, rules)
|
|
106
|
+
when :leaf then [true, :open]
|
|
107
|
+
when :prefix then [true, :narrow]
|
|
108
|
+
else [false, nil]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|