markdownr 0.1.0 → 0.2.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: c3e87f1a091aae321ef6ed25f1780ffc034278328d6432762d7126947029cf63
4
- data.tar.gz: aced5406c9386d7f09f46484bec59c4e6ef4df7dab40d0fa497ed37c1a4da83c
3
+ metadata.gz: eb86e2f05b5c71f9365fba3fdcf497f1c53439e7b395d13efb674eb36947f3dc
4
+ data.tar.gz: 3c2d3da0044862746e47d76c386712b8ab8a0f6c1a7c60514e511953e2af5542
5
5
  SHA512:
6
- metadata.gz: 923029aa7a8065c52983debabd69ae1e411a24cf8168b13629457ff8f0dbb4ceafc93db7b4755d73cbb2f443469200534e06b3316901ef0a03cf70f157551ef1
7
- data.tar.gz: 533e4b984b363692a3fa7a8269818d246cbd5f5846a92103f3eb9b250e57376bcf030953fbc71496502190a93d7951bebbc9610271874c1ec4f25e2ef78426d1
6
+ metadata.gz: 1aa636326b55bbff250fa04e13f5befadda9791fbabdf175c7c774886d8df793ee4e1ecc93d59a3bc01428eaa26e2c104ab6b831dd0e1323666e970fd64be634
7
+ data.tar.gz: 1ad0d23592aa3874b519cfa3afbfc310bc0fd12c90c223a54bf4a530e1e7ca7da77d1c00a548210fde5a043ffad2f52460f40a2989cb30b2f900db666ad3fac1
@@ -7,6 +7,7 @@ require "json"
7
7
  require "uri"
8
8
  require "cgi"
9
9
  require "pathname"
10
+ require "set"
10
11
 
11
12
  module MarkdownServer
12
13
  class App < Sinatra::Base
@@ -192,6 +193,150 @@ module MarkdownServer
192
193
  return settings.custom_title if settings.respond_to?(:custom_title) && settings.custom_title
193
194
  File.basename(root_dir).gsub(/[-_]/, " ").gsub(/\b\w/, &:upcase)
194
195
  end
196
+
197
+ def search_form_path(relative_path)
198
+ "/search/" + relative_path.split("/").map { |p| encode_path_component(p) }.join("/")
199
+ end
200
+
201
+ def parent_dir_path(relative_path)
202
+ parts = relative_path.split("/")
203
+ parts.length > 1 ? parts[0..-2].join("/") : ""
204
+ end
205
+
206
+ BINARY_EXTENSIONS = %w[
207
+ .png .jpg .jpeg .gif .bmp .ico .svg .webp
208
+ .pdf .epub .mobi
209
+ .zip .gz .tar .bz2 .7z .rar
210
+ .exe .dll .so .dylib .o
211
+ .mp3 .mp4 .avi .mov .wav .flac .ogg
212
+ .woff .woff2 .ttf .eot .otf
213
+ .pyc .class .beam
214
+ .sqlite .db
215
+ ].freeze
216
+
217
+ MAX_SEARCH_FILES = 100
218
+ MAX_FILE_READ_BYTES = 512_000 # 500KB
219
+ CONTEXT_LINES = 2 # lines before/after match to send
220
+ MAX_LINE_DISPLAY = 200 # chars before truncating a line
221
+
222
+ def search_files(dir_path, regexes)
223
+ results = []
224
+ base = File.realpath(root_dir)
225
+
226
+ catch(:search_limit) do
227
+ walk_directory(dir_path) do |file_path|
228
+ throw :search_limit if results.length >= MAX_SEARCH_FILES
229
+
230
+ content = File.binread(file_path, MAX_FILE_READ_BYTES) rescue next
231
+ content.force_encoding("utf-8")
232
+ next unless content.valid_encoding?
233
+
234
+ # All regexes must match somewhere in the file
235
+ next unless regexes.all? { |re| re.match?(content) }
236
+
237
+ relative = file_path.sub("#{base}/", "")
238
+ lines = content.lines
239
+ matches = collect_matching_lines(lines, regexes)
240
+
241
+ results << { path: relative, matches: matches }
242
+ end
243
+ end
244
+
245
+ results
246
+ end
247
+
248
+ def walk_directory(dir_path, &block)
249
+ Dir.entries(dir_path).sort.each do |entry|
250
+ next if entry.start_with?(".") || EXCLUDED.include?(entry)
251
+ full = File.join(dir_path, entry)
252
+
253
+ if File.directory?(full)
254
+ walk_directory(full, &block)
255
+ elsif File.file?(full)
256
+ ext = File.extname(entry).downcase
257
+ next if BINARY_EXTENSIONS.include?(ext)
258
+ block.call(full)
259
+ end
260
+ end
261
+ end
262
+
263
+ def collect_matching_lines(lines, regexes)
264
+ match_indices = Set.new
265
+ lines.each_with_index do |line, i|
266
+ if regexes.any? { |re| re.match?(line) }
267
+ match_indices << i
268
+ end
269
+ end
270
+
271
+ # Build context groups
272
+ groups = []
273
+ sorted = match_indices.sort
274
+
275
+ sorted.each do |idx|
276
+ range_start = [idx - CONTEXT_LINES, 0].max
277
+ range_end = [idx + CONTEXT_LINES, lines.length - 1].min
278
+
279
+ if groups.last && range_start <= groups.last[:end] + 1
280
+ groups.last[:end] = range_end
281
+ else
282
+ groups << { start: range_start, end: range_end }
283
+ end
284
+ end
285
+
286
+ groups.map do |g|
287
+ context_lines = (g[:start]..g[:end]).map do |i|
288
+ distance = match_indices.include?(i) ? 0 : match_indices.map { |m| (m - i).abs }.min
289
+ { number: i + 1, text: lines[i].to_s.chomp, distance: distance }
290
+ end
291
+ { lines: context_lines }
292
+ end
293
+ end
294
+
295
+ def highlight_search_line(text, regexes, is_match)
296
+ # Build a combined regex with non-greedy quantifiers for shorter highlights
297
+ combined = Regexp.union(regexes.map { |r|
298
+ Regexp.new(r.source.gsub(/(?<!\\)([*+}])(?!\?)/, '\1?'), r.options)
299
+ })
300
+
301
+ # Truncate long lines, centering around the first match
302
+ prefix_trunc = false
303
+ suffix_trunc = false
304
+ if text.length > MAX_LINE_DISPLAY
305
+ if is_match && (m = combined.match(text))
306
+ center = m.begin(0) + m[0].length / 2
307
+ half = MAX_LINE_DISPLAY / 2
308
+ start = [[center - half, 0].max, [text.length - MAX_LINE_DISPLAY, 0].max].min
309
+ else
310
+ start = 0
311
+ end
312
+ prefix_trunc = start > 0
313
+ suffix_trunc = (start + MAX_LINE_DISPLAY) < text.length
314
+ text = text[start, MAX_LINE_DISPLAY]
315
+ end
316
+
317
+ html = ""
318
+ html << '<span class="truncated">...</span>' if prefix_trunc
319
+ if is_match
320
+ pieces = text.split(combined)
321
+ matches = text.scan(combined)
322
+ pieces.each_with_index do |piece, i|
323
+ html << h(piece)
324
+ html << %(<span class="highlight-match">#{h(matches[i])}</span>) if matches[i]
325
+ end
326
+ else
327
+ html << h(text)
328
+ end
329
+ html << '<span class="truncated">...</span>' if suffix_trunc
330
+ html
331
+ end
332
+
333
+ def compile_regexes(query)
334
+ words = query.split(/\s+/).reject(&:empty?)
335
+ return nil if words.empty?
336
+ words.map { |w| Regexp.new(w, Regexp::IGNORECASE) }
337
+ rescue RegexpError => e
338
+ raise RegexpError, e.message
339
+ end
195
340
  end
196
341
 
197
342
  # Routes
@@ -225,6 +370,36 @@ module MarkdownServer
225
370
  send_file real_path, disposition: "attachment"
226
371
  end
227
372
 
373
+ get "/search/?*" do
374
+ requested = params["splat"].first.to_s.chomp("/")
375
+ @query = params[:q].to_s.strip
376
+
377
+ if requested.empty?
378
+ search_dir = File.realpath(root_dir)
379
+ else
380
+ search_dir = safe_path(requested)
381
+ halt 404 unless File.directory?(search_dir)
382
+ end
383
+
384
+ @path = requested
385
+ @crumbs = breadcrumbs(requested)
386
+ @title = requested.empty? ? dir_title : File.basename(requested)
387
+ @results = []
388
+ @regexes = nil
389
+ @error = nil
390
+
391
+ unless @query.empty?
392
+ begin
393
+ @regexes = compile_regexes(@query)
394
+ @results = search_files(search_dir, @regexes) if @regexes
395
+ rescue RegexpError => e
396
+ @error = "Invalid regex: #{e.message}"
397
+ end
398
+ end
399
+
400
+ erb :search
401
+ end
402
+
228
403
  private
229
404
 
230
405
  def render_directory(real_path, relative_path)
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/views/directory.erb CHANGED
@@ -15,7 +15,13 @@
15
15
  })();
16
16
  </script>
17
17
 
18
- <h1 class="page-title"><%= h(@title) %></h1>
18
+ <div class="title-bar">
19
+ <h1 class="page-title"><%= h(@title) %></h1>
20
+ <form class="search-form" action="<%= search_form_path(@path) %>" method="get">
21
+ <input type="text" name="q" placeholder="Search files..." value="<%= h(params[:q].to_s) %>">
22
+ <button type="submit">Search</button>
23
+ </form>
24
+ </div>
19
25
 
20
26
  <div class="dir-header">
21
27
  <div class="dir-count">
data/views/layout.erb CHANGED
@@ -416,12 +416,170 @@
416
416
  .toc-mobile .toc-h3 { padding-left: 0.8rem; }
417
417
  .toc-mobile .toc-h4 { padding-left: 1.4rem; }
418
418
 
419
+ /* Title bar with search */
420
+ .title-bar {
421
+ display: flex;
422
+ align-items: baseline;
423
+ gap: 1rem;
424
+ margin: 0 0 1.2rem;
425
+ border-bottom: 2px solid #d4b96a;
426
+ padding-bottom: 0.5rem;
427
+ }
428
+ .title-bar h1.page-title {
429
+ border-bottom: none;
430
+ padding-bottom: 0;
431
+ margin: 0;
432
+ flex: 1;
433
+ min-width: 0;
434
+ }
435
+ .search-form {
436
+ display: flex;
437
+ flex-shrink: 0;
438
+ }
439
+ .search-form input[type="text"] {
440
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
441
+ font-size: 0.82rem;
442
+ padding: 0.3rem 0.6rem;
443
+ border: 1px solid #d4b96a;
444
+ border-radius: 4px 0 0 4px;
445
+ background: #fdfcf9;
446
+ color: #2c2c2c;
447
+ width: 180px;
448
+ outline: none;
449
+ transition: border-color 0.15s;
450
+ }
451
+ .search-form input[type="text"]:focus {
452
+ border-color: #8b6914;
453
+ }
454
+ .search-form input[type="text"]::placeholder {
455
+ color: #bbb;
456
+ }
457
+ .search-form button {
458
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
459
+ font-size: 0.82rem;
460
+ padding: 0.3rem 0.6rem;
461
+ border: 1px solid #d4b96a;
462
+ border-left: none;
463
+ border-radius: 0 4px 4px 0;
464
+ background: #f5f0e4;
465
+ color: #8b6914;
466
+ cursor: pointer;
467
+ transition: background 0.15s;
468
+ }
469
+ .search-form button:hover {
470
+ background: #e8dfc8;
471
+ }
472
+
473
+ /* Search results */
474
+ .search-summary {
475
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
476
+ font-size: 0.85rem;
477
+ color: #888;
478
+ margin-bottom: 1.2rem;
479
+ }
480
+ .search-error {
481
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
482
+ font-size: 0.9rem;
483
+ color: #c44;
484
+ background: #fdf0f0;
485
+ border: 1px solid #f0c0c0;
486
+ border-radius: 6px;
487
+ padding: 0.8rem 1rem;
488
+ margin-bottom: 1.2rem;
489
+ }
490
+ .search-result {
491
+ margin-bottom: 1.5rem;
492
+ }
493
+ .search-result-path {
494
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
495
+ font-size: 0.9rem;
496
+ margin-bottom: 0.4rem;
497
+ }
498
+ .search-result-path a {
499
+ color: #8b6914;
500
+ text-decoration: none;
501
+ }
502
+ .search-result-path a:hover {
503
+ text-decoration: underline;
504
+ }
505
+ .search-result-path .icon {
506
+ margin-right: 0.3rem;
507
+ }
508
+ .search-context {
509
+ background: #2d2d2d;
510
+ color: #f0f0f0;
511
+ border-radius: 6px;
512
+ overflow: hidden;
513
+ font-family: "SF Mono", Menlo, Consolas, monospace;
514
+ font-size: 0.8rem;
515
+ line-height: 1.5;
516
+ }
517
+ .search-context-group {
518
+ padding: 0.4rem 0;
519
+ }
520
+ .search-context-group + .search-context-group {
521
+ border-top: 1px dashed #555;
522
+ }
523
+ .search-line {
524
+ display: flex;
525
+ padding: 0 0.8rem;
526
+ max-width: 100%;
527
+ }
528
+ .search-line-num {
529
+ color: #75715e;
530
+ text-align: right;
531
+ min-width: 3.5em;
532
+ padding-right: 1em;
533
+ flex-shrink: 0;
534
+ user-select: none;
535
+ }
536
+ .search-line-text {
537
+ flex: 1;
538
+ min-width: 0;
539
+ white-space: pre;
540
+ overflow: hidden;
541
+ text-overflow: ellipsis;
542
+ }
543
+ .search-line.match-line {
544
+ background: rgba(212, 185, 106, 0.15);
545
+ }
546
+ .search-line .highlight-match {
547
+ background: #b8860b;
548
+ color: #fff;
549
+ font-weight: 600;
550
+ padding: 0.05em 0.2em;
551
+ border-radius: 2px;
552
+ }
553
+ .search-line .truncated {
554
+ color: #75715e;
555
+ font-style: italic;
556
+ }
557
+ .search-line.ctx-2,
558
+ .search-line.ctx-3,
559
+ .search-line.ctx-4 {
560
+ display: none;
561
+ }
562
+ .search-no-results {
563
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
564
+ font-size: 0.95rem;
565
+ color: #888;
566
+ text-align: center;
567
+ padding: 2rem 0;
568
+ }
569
+
419
570
  /* Responsive */
420
571
  @media (max-width: 768px) {
421
572
  .container { padding: 1rem; }
422
573
  .container.has-toc { max-width: 900px; }
423
574
  h1.page-title { font-size: 1.3rem; }
424
575
 
576
+ .title-bar {
577
+ flex-wrap: wrap;
578
+ }
579
+ .search-form input[type="text"] {
580
+ width: 140px;
581
+ }
582
+
425
583
  .page-with-toc { display: block; }
426
584
  .toc-sidebar { display: none; }
427
585
  .toc-mobile { display: block; }
data/views/markdown.erb CHANGED
@@ -1,4 +1,11 @@
1
- <h1 class="page-title"><%= h(@title) %></h1>
1
+ <% _parent = parent_dir_path(@crumbs.map { |c| c[:name] }.drop(1).join("/")) %>
2
+ <div class="title-bar">
3
+ <h1 class="page-title"><%= h(@title) %></h1>
4
+ <form class="search-form" action="<%= search_form_path(_parent) %>" method="get">
5
+ <input type="text" name="q" placeholder="Search files..." value="">
6
+ <button type="submit">Search</button>
7
+ </form>
8
+ </div>
2
9
 
3
10
  <div class="toolbar">
4
11
  <a href="<%= @download_href %>">Download</a>
data/views/raw.erb CHANGED
@@ -1,4 +1,11 @@
1
- <h1 class="page-title"><%= h(@title) %></h1>
1
+ <% _parent = parent_dir_path(@crumbs.map { |c| c[:name] }.drop(1).join("/")) %>
2
+ <div class="title-bar">
3
+ <h1 class="page-title"><%= h(@title) %></h1>
4
+ <form class="search-form" action="<%= search_form_path(_parent) %>" method="get">
5
+ <input type="text" name="q" placeholder="Search files..." value="">
6
+ <button type="submit">Search</button>
7
+ </form>
8
+ </div>
2
9
 
3
10
  <div class="toolbar">
4
11
  <a href="<%= @download_href %>">Download</a>
data/views/search.erb ADDED
@@ -0,0 +1,47 @@
1
+ <div class="title-bar">
2
+ <h1 class="page-title">Search: <%= h(@title) %></h1>
3
+ <form class="search-form" action="<%= search_form_path(@path) %>" method="get">
4
+ <input type="text" name="q" placeholder="Search files..." value="<%= h(@query) %>">
5
+ <button type="submit">Search</button>
6
+ </form>
7
+ </div>
8
+
9
+ <% if @error %>
10
+ <div class="search-error"><%= h(@error) %></div>
11
+ <% elsif !@query.empty? %>
12
+ <div class="search-summary">
13
+ <% if @results.empty? %>
14
+ No results for "<%= h(@query) %>"
15
+ <% else %>
16
+ <%= @results.length %><%= @results.length >= 100 ? "+" : "" %> file<%= @results.length == 1 ? "" : "s" %> matching "<%= h(@query) %>"
17
+ <% end %>
18
+ </div>
19
+ <% end %>
20
+
21
+ <% if @results.empty? && @error.nil? && !@query.empty? %>
22
+ <div class="search-no-results">No matching files found.</div>
23
+ <% end %>
24
+
25
+ <% @results.each do |result| %>
26
+ <div class="search-result">
27
+ <div class="search-result-path">
28
+ <span class="icon"><%= icon_for(File.basename(result[:path]), false) %></span>
29
+ <a href="/browse/<%= result[:path].split("/").map { |p| encode_path_component(p) }.join("/") %>"><%= h(result[:path]) %></a>
30
+ </div>
31
+ <% unless result[:matches].empty? %>
32
+ <div class="search-context">
33
+ <% result[:matches].each do |group| %>
34
+ <div class="search-context-group">
35
+ <% group[:lines].each do |line| %>
36
+ <% css_class = line[:distance] == 0 ? "search-line match-line" : "search-line ctx-#{line[:distance]}" %>
37
+ <div class="<%= css_class %>">
38
+ <span class="search-line-num"><%= line[:number] %></span>
39
+ <span class="search-line-text"><%= highlight_search_line(line[:text], @regexes, line[:distance] == 0) %></span>
40
+ </div>
41
+ <% end %>
42
+ </div>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ <% end %>
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn
@@ -108,6 +108,7 @@ files:
108
108
  - views/layout.erb
109
109
  - views/markdown.erb
110
110
  - views/raw.erb
111
+ - views/search.erb
111
112
  homepage: https://github.com/brianmd/markdown-server
112
113
  licenses:
113
114
  - MIT