markdownr 0.8.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2cb82d0149fbfce233b73c5dac9743b941dab4242eb7a28431e4c796c89dc1b
4
- data.tar.gz: eafe8d88da6cac6033d80f3aeae204c3f99b19d99ef09102389e0095112fd173
3
+ metadata.gz: e25bef09e610cf90ddade4977a1ab04e69c5eff89e1904e7f25edebeacd11d05
4
+ data.tar.gz: ef7183813e31254cc73a8be5776e445dc67830c3387629724c3110707205ed50
5
5
  SHA512:
6
- metadata.gz: 92eac0f8d752b3c4daf1fa484feb3ae56f2eef2c7b64e8b6e757a5377c5f17655acecae47466a7a5dcad53be1ef06f4e00c62e81afc9f776fdb8f963fb154a42
7
- data.tar.gz: 80dd17b97f0f869381b79624b91a15b025514a9b5d58e65f1b1943a5dce6f7338ad9abbe2cc3436b268f072800adce4135624d94c5346afc970a7549b73a2d3d
6
+ metadata.gz: e9528e7bf4f77ebf68486cf0ae3ac72042084365a6e8853a0ea638b6af1bc7edb2986c46ce0612c42880e38c34e9f169ce5ec93759af79253ad93a0f7f5d38ed
7
+ data.tar.gz: 4ccd4fac98ce2a4ce78d04bc1dec843be9648baa949cc5510f8da559aeb2b59d8db5a980b5a4da50cd60873f5c15662e066e793b0a151da14723e894056bc8e7
data/bin/markdownr CHANGED
@@ -32,6 +32,14 @@ OptionParser.new do |opts|
32
32
  options[:behind_proxy] = true
33
33
  end
34
34
 
35
+ opts.on("--admin", "Grant admin access to every visitor (use only on trusted local networks)") do
36
+ options[:admin_all] = true
37
+ end
38
+
39
+ opts.on("--admin-only", "Block all routes except /admin/login until logged in as admin") do
40
+ options[:admin_only] = true
41
+ end
42
+
35
43
  opts.on("-i", "--index-file FILENAME", "Render this file instead of the directory listing when present (e.g. index.md, moc.md)") do |f|
36
44
  options[:index_file] = f
37
45
  end
@@ -48,6 +56,18 @@ OptionParser.new do |opts|
48
56
  options[:verbose] = true
49
57
  end
50
58
 
59
+ opts.on("--default-vim", "Open the in-browser editor in vim mode by default (per-user toggle still wins via localStorage)") do
60
+ options[:default_vim] = true
61
+ end
62
+
63
+ opts.on("--allow-editor", "Enable the in-browser file editor (markdown / source). Without this flag, the Edit button is hidden and all editor write/preview/source routes return errors, even for admins.") do
64
+ options[:allow_editor] = true
65
+ end
66
+
67
+ opts.on("--allow-csv-editor", "Enable CSV editing (row update, duplicate, delete, add-on actions). Without this flag, CSV tables are forced read-only on the server and in the UI, even for admins.") do
68
+ options[:allow_csv_editor] = true
69
+ end
70
+
51
71
  opts.on("--plugin NAME", "Enable a plugin by name (can be specified multiple times)") do |name|
52
72
  options[:plugin_overrides] = (options[:plugin_overrides] || {}).merge(
53
73
  name => (options.dig(:plugin_overrides, name) || {}).merge("enabled" => true)
@@ -58,6 +78,14 @@ OptionParser.new do |opts|
58
78
  (options[:plugin_dirs] ||= []) << d
59
79
  end
60
80
 
81
+ opts.on("--follow-link PATH", Array, "Allow this symlink (inside the served dir) to escape to its target. Repeatable; comma-separated values also accepted.") do |paths|
82
+ (options[:follow_links] ||= []).concat(paths)
83
+ end
84
+
85
+ opts.on("--unhide ENTRY", "Show a normally-hidden entry (repeatable). Bare name matches anywhere; @/path is project-root-anchored; multi-segment narrows the listing.") do |v|
86
+ (options[:unhide] ||= []) << v
87
+ end
88
+
61
89
  opts.on("-v", "--version", "Show version") do
62
90
  puts "markdownr #{MarkdownServer::VERSION}"
63
91
  exit
@@ -74,18 +102,39 @@ end
74
102
 
75
103
  MarkdownServer::App.set :root_dir, dir
76
104
  MarkdownServer::App.set :behind_proxy, options[:behind_proxy] || false
105
+ MarkdownServer::App.set :admin_all, options[:admin_all] || false
106
+ MarkdownServer::App.set :admin_only, options[:admin_only] || false
77
107
  MarkdownServer::App.set :custom_title, options[:title]
78
108
  MarkdownServer::App.set :allow_robots, options[:allow_robots] || false
79
109
  MarkdownServer::App.set :index_file, options[:index_file]
80
110
  MarkdownServer::App.set :link_tooltips, options.fetch(:link_tooltips, true)
81
111
  MarkdownServer::App.set :hard_wrap, options.fetch(:hard_wrap, true)
82
112
  MarkdownServer::App.set :verbose, options[:verbose] || false
113
+ MarkdownServer::App.set :default_vim, options[:default_vim] || false
114
+ MarkdownServer::App.set :allow_editor, options[:allow_editor] || false
115
+ MarkdownServer::App.set :allow_csv_editor, options[:allow_csv_editor] || false
83
116
  MarkdownServer::App.set :port, options[:port]
84
117
  MarkdownServer::App.set :bind, options[:bind]
85
118
  MarkdownServer::App.set :server_settings, { max_threads: options[:threads], min_threads: 1 }
86
119
  MarkdownServer::App.set :plugin_overrides, options[:plugin_overrides] || {}
87
120
  MarkdownServer::App.set :plugin_dirs, options[:plugin_dirs] || []
88
121
 
122
+ root_real = File.realpath(dir)
123
+ followed_links = (options[:follow_links] || []).filter_map do |p|
124
+ expanded = File.expand_path(p, dir)
125
+ unless File.exist?(expanded)
126
+ $stderr.puts "Warning: --follow-link path does not exist, skipping: #{p}"
127
+ next nil
128
+ end
129
+ link_real = File.realpath(expanded)
130
+ unless expanded == root_real || expanded.start_with?("#{root_real}/")
131
+ $stderr.puts "Warning: --follow-link path is outside the served dir, skipping: #{p}"
132
+ next nil
133
+ end
134
+ link_real
135
+ end.uniq
136
+ MarkdownServer::App.set :followed_links, followed_links
137
+
89
138
  # Load .markdownr.yml popup settings
90
139
  config_path = File.join(dir, ".markdownr.yml")
91
140
  if File.exist?(config_path)
@@ -102,6 +151,10 @@ if File.exist?(config_path)
102
151
  MarkdownServer::App.set :plugin_dirs, (yaml["plugin_dirs"] + existing).uniq
103
152
  end
104
153
 
154
+ if yaml && yaml.key?("admin_only") && !options[:admin_only]
155
+ MarkdownServer::App.set :admin_only, yaml["admin_only"] == true
156
+ end
157
+
105
158
  # Load CSV database config (top-level key or legacy plugin key)
106
159
  csv_db_paths = yaml&.dig("csv_databases") || yaml&.dig("plugins", "csv_browser", "databases")
107
160
  if csv_db_paths.is_a?(Array) && !csv_db_paths.empty?
@@ -118,6 +171,17 @@ if File.exist?(config_path)
118
171
  end
119
172
  end
120
173
 
174
+ # Compile and apply unhide rules (YAML + CLI, additive merge).
175
+ yaml_unhide = (defined?(yaml) && yaml.is_a?(Hash) && yaml["unhide"].is_a?(Array)) ? yaml["unhide"] : []
176
+ combined_unhide = (yaml_unhide + (options[:unhide] || [])).uniq
177
+ unless combined_unhide.empty?
178
+ begin
179
+ MarkdownServer::App.set :unhide_rules, MarkdownServer::Unhide.compile(combined_unhide)
180
+ rescue MarkdownServer::Unhide::CompileError => e
181
+ abort "Invalid unhide config: #{e.message}"
182
+ end
183
+ end
184
+
121
185
  MarkdownServer::App.load_plugins!
122
186
 
123
187
  puts "Serving #{dir} on http://#{options[:bind]}:#{options[:port]}/"
data/bin/start-claude ADDED
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ claude --chrome
@@ -10,11 +10,14 @@ require "pathname"
10
10
  require "set"
11
11
  require "net/http"
12
12
  require "base64"
13
+ require "digest"
13
14
  require_relative "plugin"
14
15
  require_relative "csv_browser/config_loader"
15
16
  require_relative "csv_browser/table_reader"
16
17
  require_relative "csv_browser/addon_registry"
17
18
  require_relative "csv_browser/row_context"
19
+ require_relative "permitted_bases"
20
+ require_relative "unhide"
18
21
  require_relative "helpers/path_helpers"
19
22
  require_relative "helpers/formatting_helpers"
20
23
  require_relative "helpers/markdown_helpers"
@@ -41,6 +44,8 @@ module MarkdownServer
41
44
  set :protection, false
42
45
  set :host_authorization, { permitted_hosts: [] }
43
46
  set :behind_proxy, false
47
+ set :admin_all, false
48
+ set :admin_only, false
44
49
  set :verbose, false
45
50
  set :session_secret, ENV.fetch("MARKDOWNR_SESSION_SECRET", SecureRandom.hex(64))
46
51
  set :sessions, key: "markdownr_session", same_site: :strict, httponly: true
@@ -53,6 +58,11 @@ module MarkdownServer
53
58
  set :dictionary_url, nil
54
59
  set :plugin_dirs, []
55
60
  set :csv_browser_config, nil
61
+ set :followed_links, []
62
+ set :unhide_rules, []
63
+ set :default_vim, false
64
+ set :allow_editor, false
65
+ set :allow_csv_editor, false
56
66
  end
57
67
 
58
68
  def self.load_plugins!
@@ -83,16 +93,21 @@ module MarkdownServer
83
93
  end
84
94
 
85
95
  before do
86
- if request.path_info.start_with?("/browser", "/csv-browser")
87
- cache_control :no_cache, :no_store, :must_revalidate
88
- else
89
- cache_control :public, max_age: 14400
90
- end
91
-
92
96
  if settings.verbose
93
97
  $stdout.puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} #{client_ip} #{request.request_method} #{request.fullpath}"
94
98
  $stdout.flush
95
99
  end
100
+
101
+ if admin_only_mode? && !admin_only_public_route?(request.path_info) && !admin?
102
+ cache_control :no_cache, :no_store, :must_revalidate
103
+ redirect "/admin/login?return_to=#{CGI.escape(request.fullpath)}"
104
+ end
105
+
106
+ if request.path_info.start_with?("/browser", "/csv-browser", "/__markdownr/api")
107
+ cache_control :no_cache, :no_store, :must_revalidate
108
+ else
109
+ cache_control :public, max_age: 14400
110
+ end
96
111
  end
97
112
 
98
113
  get "/" do
@@ -110,6 +125,175 @@ module MarkdownServer
110
125
  { ok: true }.to_json
111
126
  end
112
127
 
128
+ EDITOR_FILE_MAX_BYTES = 5 * 1024 * 1024
129
+
130
+ def editor_loader_path
131
+ @@editor_loader_path ||= File.expand_path("assets/editor-loader.js", __dir__)
132
+ end
133
+
134
+ def editor_loader_version
135
+ path = editor_loader_path
136
+ content = File.read(path) + editor_import_map_json
137
+ Digest::SHA256.hexdigest(content)[0, 10]
138
+ end
139
+
140
+ EDITOR_PKGS = {
141
+ "@codemirror/state" => "6.6.0",
142
+ "@codemirror/view" => "6.41.1",
143
+ "@codemirror/commands" => "6.10.3",
144
+ "@codemirror/language" => "6.12.3",
145
+ "@codemirror/search" => "6.7.0",
146
+ "@codemirror/autocomplete" => "6.20.1",
147
+ "@codemirror/theme-one-dark" => "6.1.3",
148
+ "@codemirror/lang-markdown" => "6.5.0",
149
+ "@codemirror/lang-javascript" => "6.2.5",
150
+ "@codemirror/lang-json" => "6.0.2",
151
+ "@codemirror/lang-yaml" => "6.1.3",
152
+ "@codemirror/lang-html" => "6.4.11",
153
+ "@codemirror/lang-css" => "6.3.1",
154
+ "@codemirror/lang-python" => "6.2.1",
155
+ "@codemirror/legacy-modes" => "6.5.2",
156
+ "@replit/codemirror-vim" => "6.3.0"
157
+ }.freeze
158
+
159
+ EDITOR_SHARED_DEPS = %w[
160
+ @codemirror/state
161
+ @codemirror/view
162
+ @codemirror/language
163
+ @codemirror/commands
164
+ ].freeze
165
+
166
+ def editor_import_map_json
167
+ @@editor_import_map_json ||= begin
168
+ imports = {}
169
+ EDITOR_PKGS.each do |pkg, ver|
170
+ deps = EDITOR_SHARED_DEPS.reject { |d| d == pkg }
171
+ imports[pkg] = "https://esm.sh/#{pkg}@#{ver}?external=#{deps.join(",")}"
172
+ end
173
+ EDITOR_PKGS.each do |pkg, ver|
174
+ next unless pkg == "@codemirror/legacy-modes"
175
+ %w[ruby shell].each do |mode|
176
+ deps = EDITOR_SHARED_DEPS.reject { |d| d == pkg }
177
+ imports["#{pkg}/mode/#{mode}"] =
178
+ "https://esm.sh/#{pkg}@#{ver}/mode/#{mode}.js?external=#{deps.join(",")}"
179
+ end
180
+ end
181
+ JSON.dump({ imports: imports })
182
+ end
183
+ end
184
+
185
+ get "/__markdownr/editor-loader.js" do
186
+ halt 404, "editor disabled" unless settings.allow_editor
187
+ cache_control :public, max_age: 14400
188
+ send_file File.expand_path("assets/editor-loader.js", __dir__),
189
+ type: "application/javascript"
190
+ end
191
+
192
+ get "/__markdownr/api/file/source/?*" do
193
+ halt 404, '{"error":"editor disabled"}' unless settings.allow_editor
194
+ requested = params["splat"].first.to_s.chomp("/")
195
+ halt 400, '{"error":"missing path"}' if requested.empty?
196
+
197
+ real_path = safe_path(requested)
198
+ halt 404, '{"error":"not a file"}' unless File.file?(real_path)
199
+
200
+ size = File.size(real_path)
201
+ halt 413, { error: "file too large", max: EDITOR_FILE_MAX_BYTES, size: size }.to_json if size > EDITOR_FILE_MAX_BYTES
202
+
203
+ content = File.read(real_path, encoding: "utf-8")
204
+ etag = Digest::SHA256.hexdigest(content)
205
+ headers "ETag" => %("#{etag}")
206
+ headers "Last-Modified" => File.mtime(real_path).httpdate
207
+ content_type "text/plain; charset=utf-8"
208
+ cache_control :no_cache, :no_store, :must_revalidate
209
+ content
210
+ end
211
+
212
+ put "/__markdownr/api/file/?*" do
213
+ content_type :json
214
+ cache_control :no_cache, :no_store, :must_revalidate
215
+ unless settings.allow_editor
216
+ halt 403, { error: "File editor disabled on this server. Restart with --allow-editor to enable." }.to_json
217
+ end
218
+ unless admin?
219
+ halt 403, { error: "Admin login required to save changes.",
220
+ admin_url: "https://github.com/brianmd/markdown-server#admin-access",
221
+ client_ip: client_ip }.to_json
222
+ end
223
+
224
+ requested = params["splat"].first.to_s.chomp("/")
225
+ halt 400, { error: "missing path" }.to_json if requested.empty?
226
+
227
+ real_path = safe_path(requested)
228
+ halt 404, { error: "not a file" }.to_json unless File.file?(real_path)
229
+
230
+ body = request.body.read
231
+ halt 413, { error: "file too large", max: EDITOR_FILE_MAX_BYTES, size: body.bytesize }.to_json if body.bytesize > EDITOR_FILE_MAX_BYTES
232
+
233
+ body = body.force_encoding("utf-8")
234
+ halt 400, { error: "body is not valid UTF-8" }.to_json unless body.valid_encoding?
235
+
236
+ if_match = request.env["HTTP_IF_MATCH"].to_s.strip
237
+ force = if_match.empty? || if_match == "*"
238
+
239
+ unless force
240
+ current = File.read(real_path, encoding: "utf-8")
241
+ current_etag = Digest::SHA256.hexdigest(current)
242
+ provided = if_match.gsub(/\A"|"\z/, "")
243
+ if provided != current_etag
244
+ halt 409, { error: "stale", current_etag: current_etag }.to_json
245
+ end
246
+ end
247
+
248
+ tmp_path = "#{real_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
249
+ File.write(tmp_path, body)
250
+ File.rename(tmp_path, real_path)
251
+
252
+ logger.info("file edit: #{client_ip} #{requested} #{body.bytesize}b") if respond_to?(:logger) && logger
253
+ new_etag = Digest::SHA256.hexdigest(body)
254
+ { etag: new_etag, mtime: File.mtime(real_path).iso8601, bytes: body.bytesize }.to_json
255
+ end
256
+
257
+ post "/__markdownr/api/render/preview" do
258
+ content_type :json
259
+ cache_control :no_cache, :no_store, :must_revalidate
260
+ unless settings.allow_editor
261
+ halt 403, { error: "File editor disabled on this server. Restart with --allow-editor to enable." }.to_json
262
+ end
263
+ unless admin?
264
+ halt 403, { error: "Admin login required to preview edits.",
265
+ admin_url: "https://github.com/brianmd/markdown-server#admin-access",
266
+ client_ip: client_ip }.to_json
267
+ end
268
+
269
+ payload = JSON.parse(request.body.read) rescue {}
270
+ content = payload["content"].to_s
271
+ halt 413, { error: "content too large" }.to_json if content.bytesize > EDITOR_FILE_MAX_BYTES
272
+
273
+ wiki_dir = payload["current_wiki_dir"].to_s
274
+ base_real = File.realpath(root_dir)
275
+ if wiki_dir.empty?
276
+ @current_wiki_dir = base_real
277
+ else
278
+ candidate = File.expand_path(wiki_dir, base_real)
279
+ @current_wiki_dir = (candidate == base_real || candidate.start_with?("#{base_real}/")) ? candidate : base_real
280
+ end
281
+
282
+ meta, body = parse_frontmatter(content)
283
+ html = render_markdown(body)
284
+ settings.plugins.each { |p| html = p.post_render(html, meta, self) }
285
+
286
+ frontmatter_html = ""
287
+ if meta && !meta.empty?
288
+ rows = meta.map { |key, value|
289
+ "<tr><th>#{h(key)}</th><td>#{render_frontmatter_value(value)}</td></tr>"
290
+ }.join
291
+ frontmatter_html = %(<div class="frontmatter"><div class="frontmatter-heading">Frontmatter</div><table class="meta-table">#{rows}</table></div>)
292
+ end
293
+
294
+ { html: html, frontmatter_html: frontmatter_html }.to_json
295
+ end
296
+
113
297
  get "/setup-info" do
114
298
  @title = "Setup Info"
115
299
  @client_ip = client_ip
@@ -150,6 +334,11 @@ module MarkdownServer
150
334
  @root_title = dir_title
151
335
  @start_mode = "directory"
152
336
  @csv_databases = csv_databases_json
337
+ @initial_path = ""
338
+ @is_admin = admin?
339
+ @default_vim = settings.default_vim
340
+ @allow_editor = settings.allow_editor
341
+ @allow_csv_editor = settings.allow_csv_editor
153
342
  erb :browser, layout: false
154
343
  end
155
344
 
@@ -158,6 +347,11 @@ module MarkdownServer
158
347
  @root_title = dir_title
159
348
  @start_mode = "csv"
160
349
  @csv_databases = csv_databases_json
350
+ @initial_path = ""
351
+ @is_admin = admin?
352
+ @default_vim = settings.default_vim
353
+ @allow_editor = settings.allow_editor
354
+ @allow_csv_editor = settings.allow_csv_editor
161
355
  erb :browser, layout: false
162
356
  end
163
357
 
@@ -351,6 +545,9 @@ module MarkdownServer
351
545
 
352
546
  put "/browser/api/csv/databases/:db/tables/:table/rows/:row" do
353
547
  content_type :json
548
+ unless settings.allow_csv_editor
549
+ halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
550
+ end
354
551
  unless admin?
355
552
  halt 403, { error: "Admin login required to save changes.",
356
553
  admin_url: "https://github.com/brianmd/markdown-server#admin-access",
@@ -385,6 +582,9 @@ module MarkdownServer
385
582
 
386
583
  post "/browser/api/csv/databases/:db/tables/:table/rows/:row/duplicate" do
387
584
  content_type :json
585
+ unless settings.allow_csv_editor
586
+ halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
587
+ end
388
588
  unless admin?
389
589
  halt 403, { error: "Admin login required to duplicate rows.",
390
590
  admin_url: "https://github.com/brianmd/markdown-server#admin-access",
@@ -415,6 +615,9 @@ module MarkdownServer
415
615
 
416
616
  delete "/browser/api/csv/databases/:db/tables/:table/rows/:row" do
417
617
  content_type :json
618
+ unless settings.allow_csv_editor
619
+ halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
620
+ end
418
621
  unless admin?
419
622
  halt 403, { error: "Admin login required to delete rows.",
420
623
  admin_url: "https://github.com/brianmd/markdown-server#admin-access",
@@ -482,6 +685,9 @@ module MarkdownServer
482
685
  # Invoke an add-on action (initial call or prompt continuation).
483
686
  post "/browser/api/csv/databases/:db/tables/:table/addons/:addon/:action" do
484
687
  content_type :json
688
+ unless settings.allow_csv_editor
689
+ halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
690
+ end
485
691
  unless admin?
486
692
  halt 403, { error: "Admin login required to run add-on actions.",
487
693
  admin_url: "https://github.com/brianmd/markdown-server#admin-access",
@@ -550,6 +756,34 @@ module MarkdownServer
550
756
  }.to_json
551
757
  end
552
758
 
759
+ get "/browser/*" do
760
+ splat = params["splat"].first.to_s
761
+ pass if splat.start_with?("api/")
762
+ @title = dir_title
763
+ @root_title = dir_title
764
+ @start_mode = "directory"
765
+ @csv_databases = csv_databases_json
766
+ @initial_path = splat.chomp("/")
767
+ @is_admin = admin?
768
+ @default_vim = settings.default_vim
769
+ @allow_editor = settings.allow_editor
770
+ @allow_csv_editor = settings.allow_csv_editor
771
+ erb :browser, layout: false
772
+ end
773
+
774
+ get "/csv-browser/*" do
775
+ @title = dir_title
776
+ @root_title = dir_title
777
+ @start_mode = "csv"
778
+ @csv_databases = csv_databases_json
779
+ @initial_path = params["splat"].first.to_s.chomp("/")
780
+ @is_admin = admin?
781
+ @default_vim = settings.default_vim
782
+ @allow_editor = settings.allow_editor
783
+ @allow_csv_editor = settings.allow_csv_editor
784
+ erb :browser, layout: false
785
+ end
786
+
553
787
  get "/browse/?*" do
554
788
  requested = params["splat"].first.to_s
555
789
  requested = requested.chomp("/")
@@ -606,11 +840,7 @@ module MarkdownServer
606
840
  halt 404, '{"error":"not found"}'
607
841
  end
608
842
 
609
- halt 403, '{"error":"forbidden"}' unless real.start_with?(base)
610
-
611
- relative = real.sub("#{base}/", "")
612
- first_segment = relative.split("/").first
613
- halt 403, '{"error":"forbidden"}' if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
843
+ halt 403, '{"error":"forbidden"}' unless permitted_path?(real)
614
844
 
615
845
  halt 404, '{"error":"not found"}' unless File.file?(real) && File.extname(real).downcase == ".md"
616
846
 
@@ -756,8 +986,8 @@ module MarkdownServer
756
986
  end
757
987
 
758
988
  def render_directory(real_path, relative_path)
759
- entries = Dir.entries(real_path).reject do |e|
760
- e.start_with?(".") || EXCLUDED.include?(e)
989
+ entries = Dir.entries(real_path).select do |e|
990
+ e != "." && e != ".." && entry_admitted?(real_path, relative_path, e)
761
991
  end
762
992
 
763
993
  browse_prefix = relative_path.empty? ? "/browse/" : "/browse/#{relative_path}/"
@@ -899,8 +1129,8 @@ module MarkdownServer
899
1129
  end
900
1130
 
901
1131
  def browser_render_directory(real_path, requested)
902
- entries = Dir.entries(real_path).reject do |e|
903
- e.start_with?(".") || EXCLUDED.include?(e)
1132
+ entries = Dir.entries(real_path).select do |e|
1133
+ e != "." && e != ".." && entry_admitted?(real_path, requested, e)
904
1134
  end
905
1135
 
906
1136
  browse_prefix = requested.empty? ? "" : "#{requested}/"
@@ -927,7 +1157,12 @@ module MarkdownServer
927
1157
  item
928
1158
  end.compact
929
1159
 
930
- title = requested.empty? ? dir_title : File.basename(requested)
1160
+ title = if requested.empty?
1161
+ dir_title
1162
+ else
1163
+ parent = File.dirname(requested)
1164
+ parent == "." ? File.basename(requested) : "#{File.basename(requested)} (#{parent})"
1165
+ end
931
1166
  { type: "directory", path: requested, title: title, items: items }
932
1167
  end
933
1168
 
@@ -1046,17 +1281,7 @@ module MarkdownServer
1046
1281
  size = File.size(real_path) rescue 0
1047
1282
  { type: "download", title: title, href: download_href, size: format_size(size) }
1048
1283
  else
1049
- lang = case ext
1050
- when ".py" then "python"
1051
- when ".rb" then "ruby"
1052
- when ".csv" then "text"
1053
- when ".sh" then "bash"
1054
- when ".yaml", ".yml" then "yaml"
1055
- when ".erb" then "html"
1056
- when ".css" then "css"
1057
- when ".js" then "javascript"
1058
- else "text"
1059
- end
1284
+ lang = detect_source_language(real_path, content)
1060
1285
  { type: "code", title: title, language: lang, html: syntax_highlight(content, lang) }
1061
1286
  end
1062
1287
  end
@@ -1142,19 +1367,7 @@ module MarkdownServer
1142
1367
  if content.nil? || content.encoding == Encoding::BINARY || !content.valid_encoding?
1143
1368
  send_file real_path, disposition: "inline"
1144
1369
  else
1145
- lang = case ext
1146
- when ".py" then "python"
1147
- when ".rb" then "ruby"
1148
- when ".csv" then "text"
1149
- when ".sh" then "bash"
1150
- when ".yaml", ".yml" then "yaml"
1151
- when ".erb" then "html"
1152
- # .html and .js are handled by dedicated branches above;
1153
- # kept here for completeness if those branches are ever removed
1154
- when ".html" then "html"
1155
- when ".js" then "javascript"
1156
- else "text"
1157
- end
1370
+ lang = detect_source_language(real_path, content)
1158
1371
  @code = syntax_highlight(content, lang)
1159
1372
  @language = lang
1160
1373
  erb :raw