markdownr 0.1.0 → 0.3.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: aca98df210e2ff9be2ee3291855c8fbff33567421de36ac4f8ae384a72ba852e
4
+ data.tar.gz: bdf70db5b97981433249badd64f7203fda7abf701d5507661248d755683244c0
5
5
  SHA512:
6
- metadata.gz: 923029aa7a8065c52983debabd69ae1e411a24cf8168b13629457ff8f0dbb4ceafc93db7b4755d73cbb2f443469200534e06b3316901ef0a03cf70f157551ef1
7
- data.tar.gz: 533e4b984b363692a3fa7a8269818d246cbd5f5846a92103f3eb9b250e57376bcf030953fbc71496502190a93d7951bebbc9610271874c1ec4f25e2ef78426d1
6
+ metadata.gz: b4b70939c23cbc36194e4471f4f60e34b0110bfcca689542ced01df2c2f19ed3b928c84a9232f44180ff6e70b4c3d82317ec96d8dd2eb2202c7d1fcbbdd9eb14
7
+ data.tar.gz: 6cabae16ae4ca30e35e2a0e66f9d64794aa1c28a7afac14cead2d529ffac67c07ebf0b8016eb225a3ea5d09e8f16e3b303068be66153f74e856e36e80c1474dc
@@ -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,163 @@ 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_single_file(file_path, regexes)
223
+ base = File.realpath(root_dir)
224
+ content = File.binread(file_path, MAX_FILE_READ_BYTES) rescue return []
225
+ content.force_encoding("utf-8")
226
+ return [] unless content.valid_encoding?
227
+ return [] unless regexes.all? { |re| re.match?(content) }
228
+
229
+ relative = file_path.sub("#{base}/", "")
230
+ lines = content.lines
231
+ matches = collect_matching_lines(lines, regexes)
232
+ [{ path: relative, matches: matches }]
233
+ end
234
+
235
+ def search_files(dir_path, regexes)
236
+ results = []
237
+ base = File.realpath(root_dir)
238
+
239
+ catch(:search_limit) do
240
+ walk_directory(dir_path) do |file_path|
241
+ throw :search_limit if results.length >= MAX_SEARCH_FILES
242
+
243
+ content = File.binread(file_path, MAX_FILE_READ_BYTES) rescue next
244
+ content.force_encoding("utf-8")
245
+ next unless content.valid_encoding?
246
+
247
+ # All regexes must match somewhere in the file
248
+ next unless regexes.all? { |re| re.match?(content) }
249
+
250
+ relative = file_path.sub("#{base}/", "")
251
+ lines = content.lines
252
+ matches = collect_matching_lines(lines, regexes)
253
+
254
+ results << { path: relative, matches: matches }
255
+ end
256
+ end
257
+
258
+ results
259
+ end
260
+
261
+ def walk_directory(dir_path, &block)
262
+ Dir.entries(dir_path).sort.each do |entry|
263
+ next if entry.start_with?(".") || EXCLUDED.include?(entry)
264
+ full = File.join(dir_path, entry)
265
+
266
+ if File.directory?(full)
267
+ walk_directory(full, &block)
268
+ elsif File.file?(full)
269
+ ext = File.extname(entry).downcase
270
+ next if BINARY_EXTENSIONS.include?(ext)
271
+ block.call(full)
272
+ end
273
+ end
274
+ end
275
+
276
+ def collect_matching_lines(lines, regexes)
277
+ match_indices = Set.new
278
+ lines.each_with_index do |line, i|
279
+ if regexes.any? { |re| re.match?(line) }
280
+ match_indices << i
281
+ end
282
+ end
283
+
284
+ # Build context groups
285
+ groups = []
286
+ sorted = match_indices.sort
287
+
288
+ sorted.each do |idx|
289
+ range_start = [idx - CONTEXT_LINES, 0].max
290
+ range_end = [idx + CONTEXT_LINES, lines.length - 1].min
291
+
292
+ if groups.last && range_start <= groups.last[:end] + 1
293
+ groups.last[:end] = range_end
294
+ else
295
+ groups << { start: range_start, end: range_end }
296
+ end
297
+ end
298
+
299
+ groups.map do |g|
300
+ context_lines = (g[:start]..g[:end]).map do |i|
301
+ distance = match_indices.include?(i) ? 0 : match_indices.map { |m| (m - i).abs }.min
302
+ { number: i + 1, text: lines[i].to_s.chomp, distance: distance }
303
+ end
304
+ { lines: context_lines }
305
+ end
306
+ end
307
+
308
+ def highlight_search_line(text, regexes, is_match)
309
+ # Build a combined regex with non-greedy quantifiers for shorter highlights
310
+ combined = Regexp.union(regexes.map { |r|
311
+ Regexp.new(r.source.gsub(/(?<!\\)([*+}])(?!\?)/, '\1?'), r.options)
312
+ })
313
+
314
+ # Truncate long lines, centering around the first match
315
+ prefix_trunc = false
316
+ suffix_trunc = false
317
+ if text.length > MAX_LINE_DISPLAY
318
+ if is_match && (m = combined.match(text))
319
+ center = m.begin(0) + m[0].length / 2
320
+ half = MAX_LINE_DISPLAY / 2
321
+ start = [[center - half, 0].max, [text.length - MAX_LINE_DISPLAY, 0].max].min
322
+ else
323
+ start = 0
324
+ end
325
+ prefix_trunc = start > 0
326
+ suffix_trunc = (start + MAX_LINE_DISPLAY) < text.length
327
+ text = text[start, MAX_LINE_DISPLAY]
328
+ end
329
+
330
+ html = ""
331
+ html << '<span class="truncated">...</span>' if prefix_trunc
332
+ if is_match
333
+ pieces = text.split(combined)
334
+ matches = text.scan(combined)
335
+ pieces.each_with_index do |piece, i|
336
+ html << h(piece)
337
+ html << %(<span class="highlight-match">#{h(matches[i])}</span>) if matches[i]
338
+ end
339
+ else
340
+ html << h(text)
341
+ end
342
+ html << '<span class="truncated">...</span>' if suffix_trunc
343
+ html
344
+ end
345
+
346
+ def compile_regexes(query)
347
+ words = query.split(/\s+/).reject(&:empty?)
348
+ return nil if words.empty?
349
+ words.map { |w| Regexp.new(w, Regexp::IGNORECASE) }
350
+ rescue RegexpError => e
351
+ raise RegexpError, e.message
352
+ end
195
353
  end
196
354
 
197
355
  # Routes
@@ -225,6 +383,44 @@ module MarkdownServer
225
383
  send_file real_path, disposition: "attachment"
226
384
  end
227
385
 
386
+ get "/search/?*" do
387
+ requested = params["splat"].first.to_s.chomp("/")
388
+ @query = params[:q].to_s.strip
389
+
390
+ if requested.empty?
391
+ search_path = File.realpath(root_dir)
392
+ @is_file_search = false
393
+ else
394
+ search_path = safe_path(requested)
395
+ @is_file_search = File.file?(search_path)
396
+ halt 404 unless @is_file_search || File.directory?(search_path)
397
+ end
398
+
399
+ @path = requested
400
+ @crumbs = breadcrumbs(requested)
401
+ @title = requested.empty? ? dir_title : File.basename(requested)
402
+ @results = []
403
+ @regexes = nil
404
+ @error = nil
405
+
406
+ unless @query.empty?
407
+ begin
408
+ @regexes = compile_regexes(@query)
409
+ if @regexes
410
+ if @is_file_search
411
+ @results = search_single_file(search_path, @regexes)
412
+ else
413
+ @results = search_files(search_path, @regexes)
414
+ end
415
+ end
416
+ rescue RegexpError => e
417
+ @error = "Invalid regex: #{e.message}"
418
+ end
419
+ end
420
+
421
+ erb :search
422
+ end
423
+
228
424
  private
229
425
 
230
426
  def render_directory(real_path, relative_path)
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.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
+
23
+ </form>
24
+ </div>
19
25
 
20
26
  <div class="dir-header">
21
27
  <div class="dir-count">
data/views/layout.erb CHANGED
@@ -29,8 +29,23 @@
29
29
  .breadcrumbs {
30
30
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
31
31
  font-size: 0.85rem;
32
- margin-bottom: 1.5rem;
33
32
  color: #888;
33
+ position: fixed;
34
+ top: 0;
35
+ left: 0;
36
+ right: 0;
37
+ z-index: 100;
38
+ background: #faf8f4;
39
+ padding: 0.5rem 2rem;
40
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
41
+ transform: translateY(0);
42
+ opacity: 1;
43
+ transition: transform 0.35s ease, opacity 0.35s ease;
44
+ }
45
+ .breadcrumbs.hidden {
46
+ transform: translateY(-100%);
47
+ opacity: 0;
48
+ pointer-events: none;
34
49
  }
35
50
  .breadcrumbs a {
36
51
  color: #8b6914;
@@ -39,6 +54,12 @@
39
54
  .breadcrumbs a:hover { text-decoration: underline; }
40
55
  .breadcrumbs .sep { margin: 0 0.4rem; color: #ccc; }
41
56
 
57
+ /* Spacer to prevent content jumping under fixed breadcrumbs */
58
+ .breadcrumb-spacer {
59
+ height: 2.2rem;
60
+ margin-bottom: 0.3rem;
61
+ }
62
+
42
63
  /* Page title */
43
64
  h1.page-title {
44
65
  font-size: 1.6rem;
@@ -416,12 +437,295 @@
416
437
  .toc-mobile .toc-h3 { padding-left: 0.8rem; }
417
438
  .toc-mobile .toc-h4 { padding-left: 1.4rem; }
418
439
 
440
+ /* TOC drawer (swipe-to-reveal on mobile) */
441
+ .toc-overlay {
442
+ display: none;
443
+ position: fixed;
444
+ top: 0;
445
+ left: 0;
446
+ right: 0;
447
+ bottom: 0;
448
+ background: rgba(0, 0, 0, 0.4);
449
+ z-index: 200;
450
+ opacity: 0;
451
+ pointer-events: none;
452
+ transition: opacity 0.3s ease;
453
+ }
454
+ .toc-overlay.open {
455
+ opacity: 1;
456
+ pointer-events: auto;
457
+ }
458
+ .toc-drawer {
459
+ display: none;
460
+ position: fixed;
461
+ top: 0;
462
+ right: 0;
463
+ bottom: 0;
464
+ width: 280px;
465
+ max-width: 75vw;
466
+ background: #faf8f4;
467
+ z-index: 201;
468
+ transform: translateX(100%);
469
+ transition: transform 0.3s ease;
470
+ overflow-y: auto;
471
+ -webkit-overflow-scrolling: touch;
472
+ box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
473
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
474
+ font-size: 0.82rem;
475
+ line-height: 1.4;
476
+ }
477
+ .toc-drawer.open {
478
+ transform: translateX(0);
479
+ }
480
+ .toc-drawer-header {
481
+ display: flex;
482
+ justify-content: space-between;
483
+ align-items: center;
484
+ padding: 0.8rem 1rem;
485
+ border-bottom: 1px solid #e0d8c8;
486
+ position: sticky;
487
+ top: 0;
488
+ background: #faf8f4;
489
+ }
490
+ .toc-drawer-title {
491
+ font-weight: 600;
492
+ color: #8b6914;
493
+ font-size: 0.75rem;
494
+ text-transform: uppercase;
495
+ letter-spacing: 0.05em;
496
+ }
497
+ .toc-drawer-close {
498
+ background: none;
499
+ border: none;
500
+ font-size: 1.4rem;
501
+ color: #888;
502
+ cursor: pointer;
503
+ padding: 0 0.2rem;
504
+ line-height: 1;
505
+ }
506
+ .toc-drawer-close:hover {
507
+ color: #2c2c2c;
508
+ }
509
+ .toc-drawer ul {
510
+ list-style: none;
511
+ padding: 0.5rem 0;
512
+ margin: 0;
513
+ }
514
+ .toc-drawer li {
515
+ margin: 0;
516
+ }
517
+ .toc-drawer a {
518
+ color: #666;
519
+ text-decoration: none;
520
+ display: block;
521
+ padding: 0.4rem 1rem;
522
+ border-left: 3px solid transparent;
523
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
524
+ }
525
+ .toc-drawer a:hover {
526
+ color: #8b6914;
527
+ background: #f5f0e4;
528
+ }
529
+ .toc-drawer a.active {
530
+ color: #8b6914;
531
+ border-left-color: #8b6914;
532
+ font-weight: 600;
533
+ background: #fdfcf6;
534
+ }
535
+ .toc-drawer .toc-h3 a { padding-left: 1.8rem; font-size: 0.78rem; }
536
+ .toc-drawer .toc-h4 a { padding-left: 2.6rem; font-size: 0.75rem; }
537
+
538
+ .toc-fab {
539
+ display: none;
540
+ position: fixed;
541
+ bottom: 1.2rem;
542
+ right: 1.2rem;
543
+ z-index: 199;
544
+ width: 44px;
545
+ height: 44px;
546
+ border-radius: 50%;
547
+ border: none;
548
+ background: #8b6914;
549
+ color: #fff;
550
+ font-size: 1.2rem;
551
+ line-height: 1;
552
+ cursor: pointer;
553
+ box-shadow: 0 2px 8px rgba(0,0,0,0.25);
554
+ transition: background 0.15s, transform 0.15s;
555
+ }
556
+ .toc-fab:hover {
557
+ background: #6d5210;
558
+ }
559
+ .toc-fab:active {
560
+ transform: scale(0.95);
561
+ }
562
+ .toc-fab.hidden {
563
+ display: none !important;
564
+ }
565
+
566
+ @media (max-width: 768px) {
567
+ .toc-overlay, .toc-drawer { display: block; }
568
+ .toc-fab { display: block; }
569
+ }
570
+
571
+ /* Title bar with search */
572
+ .title-bar {
573
+ display: flex;
574
+ align-items: baseline;
575
+ gap: 1rem;
576
+ margin: 0 0 1.2rem;
577
+ border-bottom: 2px solid #d4b96a;
578
+ padding-bottom: 0.5rem;
579
+ }
580
+ .title-bar h1.page-title {
581
+ border-bottom: none;
582
+ padding-bottom: 0;
583
+ margin: 0;
584
+ flex: 1;
585
+ min-width: 0;
586
+ }
587
+ .search-form {
588
+ display: flex;
589
+ flex-shrink: 0;
590
+ }
591
+ .search-form input[type="text"] {
592
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
593
+ font-size: 0.82rem;
594
+ padding: 0.3rem 0.6rem;
595
+ border: 1px solid #d4b96a;
596
+ border-radius: 4px;
597
+ background: #fdfcf9;
598
+ color: #2c2c2c;
599
+ width: 180px;
600
+ outline: none;
601
+ transition: border-color 0.15s;
602
+ }
603
+ .search-form input[type="text"]:focus {
604
+ border-color: #8b6914;
605
+ }
606
+ .search-form input[type="text"]::placeholder {
607
+ color: #bbb;
608
+ }
609
+ /* Search results */
610
+ .search-summary {
611
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
612
+ font-size: 0.85rem;
613
+ color: #888;
614
+ margin-bottom: 1.2rem;
615
+ }
616
+ .search-error {
617
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
618
+ font-size: 0.9rem;
619
+ color: #c44;
620
+ background: #fdf0f0;
621
+ border: 1px solid #f0c0c0;
622
+ border-radius: 6px;
623
+ padding: 0.8rem 1rem;
624
+ margin-bottom: 1.2rem;
625
+ }
626
+ .search-result {
627
+ margin-bottom: 1.5rem;
628
+ }
629
+ .search-result-path {
630
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
631
+ font-size: 0.9rem;
632
+ margin-bottom: 0.4rem;
633
+ }
634
+ .search-result-path a {
635
+ color: #8b6914;
636
+ text-decoration: none;
637
+ }
638
+ .search-result-path a:hover {
639
+ text-decoration: underline;
640
+ }
641
+ .search-result-path .icon {
642
+ margin-right: 0.3rem;
643
+ }
644
+ .search-context {
645
+ background: #2d2d2d;
646
+ color: #f0f0f0;
647
+ border-radius: 6px;
648
+ overflow: hidden;
649
+ font-family: "SF Mono", Menlo, Consolas, monospace;
650
+ font-size: 0.8rem;
651
+ line-height: 1.5;
652
+ }
653
+ .search-context-group {
654
+ padding: 0.4rem 0;
655
+ display: block;
656
+ }
657
+ .search-context-group + .search-context-group {
658
+ border-top: 1px dashed #555;
659
+ }
660
+ a.search-context-link {
661
+ text-decoration: none;
662
+ color: inherit;
663
+ cursor: pointer;
664
+ transition: background 0.15s;
665
+ }
666
+ a.search-context-link:hover {
667
+ background: rgba(212, 185, 106, 0.12);
668
+ }
669
+ .search-line {
670
+ display: flex;
671
+ padding: 0 0.8rem;
672
+ max-width: 100%;
673
+ }
674
+ .search-line-num {
675
+ color: #75715e;
676
+ text-align: right;
677
+ min-width: 3.5em;
678
+ padding-right: 1em;
679
+ flex-shrink: 0;
680
+ user-select: none;
681
+ }
682
+ .search-line-text {
683
+ flex: 1;
684
+ min-width: 0;
685
+ white-space: pre;
686
+ overflow: hidden;
687
+ text-overflow: ellipsis;
688
+ }
689
+ .search-line.match-line {
690
+ background: rgba(212, 185, 106, 0.15);
691
+ }
692
+ .search-line .highlight-match {
693
+ background: #b8860b;
694
+ color: #fff;
695
+ font-weight: 600;
696
+ padding: 0.05em 0.2em;
697
+ border-radius: 2px;
698
+ }
699
+ .search-line .truncated {
700
+ color: #75715e;
701
+ font-style: italic;
702
+ }
703
+ .search-line.ctx-2,
704
+ .search-line.ctx-3,
705
+ .search-line.ctx-4 {
706
+ display: none;
707
+ }
708
+ .search-no-results {
709
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
710
+ font-size: 0.95rem;
711
+ color: #888;
712
+ text-align: center;
713
+ padding: 2rem 0;
714
+ }
715
+
419
716
  /* Responsive */
420
717
  @media (max-width: 768px) {
421
718
  .container { padding: 1rem; }
422
719
  .container.has-toc { max-width: 900px; }
423
720
  h1.page-title { font-size: 1.3rem; }
424
721
 
722
+ .title-bar {
723
+ flex-wrap: wrap;
724
+ }
725
+ .search-form input[type="text"] {
726
+ width: 140px;
727
+ }
728
+
425
729
  .page-with-toc { display: block; }
426
730
  .toc-sidebar { display: none; }
427
731
  .toc-mobile { display: block; }
@@ -443,7 +747,7 @@
443
747
 
444
748
  @media (max-width: 480px) {
445
749
  .container { padding: 0.8rem; }
446
- .breadcrumbs { font-size: 0.8rem; }
750
+ .breadcrumbs { font-size: 0.8rem; padding: 0.5rem 0.8rem; }
447
751
  .md-content { font-size: 0.95rem; }
448
752
  .md-content table { font-size: 0.8rem; }
449
753
  }
@@ -456,18 +760,91 @@
456
760
  <% @crumbs.each_with_index do |crumb, i| %>
457
761
  <% if i > 0 %><span class="sep">/</span><% end %>
458
762
  <% if i == @crumbs.length - 1 %>
459
- <%= h(crumb[:name]) %>
763
+ <% if @download_href %><a href="#"><%= h(crumb[:name]) %></a><% else %><%= h(crumb[:name]) %><% end %>
460
764
  <% else %>
461
765
  <a href="<%= crumb[:href] %>"><%= h(crumb[:name]) %></a>
462
766
  <% end %>
463
767
  <% end %>
464
768
  </nav>
769
+ <div class="breadcrumb-spacer"></div>
465
770
  <% end %>
466
771
 
467
772
  <%= yield %>
468
773
  </div>
469
774
 
470
775
  <script>
776
+ // Breadcrumb auto-hide: visible on load for 5s, hides on scroll down,
777
+ // shows for 5s on scroll up then hides again
778
+ (function() {
779
+ var bc = document.querySelector('.breadcrumbs');
780
+ if (!bc) return;
781
+
782
+ var hideTimer = null;
783
+ var hasScrolledDown = false;
784
+ var initialized = false;
785
+ var lastY = 0;
786
+
787
+ function show() {
788
+ bc.classList.remove('hidden');
789
+ clearTimeout(hideTimer);
790
+ hideTimer = setTimeout(function() {
791
+ if (hasScrolledDown) bc.classList.add('hidden');
792
+ }, 5000);
793
+ }
794
+
795
+ function hide() {
796
+ clearTimeout(hideTimer);
797
+ bc.classList.add('hidden');
798
+ }
799
+
800
+ function init() {
801
+ if (initialized) return;
802
+ initialized = true;
803
+ // Snapshot scroll position after any restore has happened
804
+ lastY = window.scrollY;
805
+ if (lastY > 0) {
806
+ hasScrolledDown = true;
807
+ show(); // visible for 5s then auto-hide
808
+ }
809
+ // If at top, breadcrumbs stay visible (no timer)
810
+ }
811
+
812
+ // Defer init so scroll-restore and _find settle first
813
+ setTimeout(init, 200);
814
+
815
+ window.addEventListener('scroll', function() {
816
+ if (!initialized) return;
817
+ var y = window.scrollY;
818
+ var delta = y - lastY;
819
+ lastY = y;
820
+
821
+ if (y <= 0) {
822
+ // At top of page — always show, no auto-hide
823
+ hasScrolledDown = false;
824
+ clearTimeout(hideTimer);
825
+ bc.classList.remove('hidden');
826
+ return;
827
+ }
828
+
829
+ if (delta > 0) {
830
+ // Scrolling down
831
+ hasScrolledDown = true;
832
+ hide();
833
+ } else if (delta < 0) {
834
+ // Scrolling up
835
+ show();
836
+ }
837
+ }, { passive: true });
838
+
839
+ // Show breadcrumbs when clicking/tapping non-interactive content
840
+ document.addEventListener('click', function(e) {
841
+ var tag = e.target.tagName;
842
+ if (e.target.closest('a, button, input, textarea, select, summary, .toc-sidebar, .toc-mobile')) return;
843
+ if (tag === 'A' || tag === 'BUTTON' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
844
+ if (hasScrolledDown) show();
845
+ });
846
+ })();
847
+
471
848
  // Wrap tables in scrollable containers
472
849
  document.querySelectorAll('.md-content table').forEach(function(table) {
473
850
  if (!table.parentElement.classList.contains('table-wrap')) {
@@ -478,33 +855,285 @@
478
855
  }
479
856
  });
480
857
 
481
- // TOC scroll spy — highlight the nearest heading
858
+ // TOC scroll spy — highlight the nearest heading (sidebar + drawer)
482
859
  (function() {
483
- var tocLinks = document.querySelectorAll('.toc-sidebar a');
484
- if (!tocLinks.length) return;
860
+ var sidebarLinks = document.querySelectorAll('.toc-sidebar a');
861
+ var drawerLinks = document.querySelectorAll('.toc-drawer a');
862
+ if (!sidebarLinks.length && !drawerLinks.length) return;
485
863
 
864
+ var refLinks = sidebarLinks.length ? sidebarLinks : drawerLinks;
486
865
  var headings = [];
487
- tocLinks.forEach(function(link) {
866
+ refLinks.forEach(function(link) {
488
867
  var id = link.getAttribute('href').slice(1);
489
868
  var el = document.getElementById(id);
490
- if (el) headings.push({ el: el, link: link });
869
+ if (el) headings.push({ el: el, id: id });
491
870
  });
492
871
 
493
872
  function update() {
494
873
  var scrollY = window.scrollY + 80;
495
- var current = null;
874
+ var currentId = null;
496
875
  for (var i = 0; i < headings.length; i++) {
497
876
  if (headings[i].el.offsetTop <= scrollY) {
498
- current = headings[i];
877
+ currentId = headings[i].id;
878
+ }
879
+ }
880
+ [sidebarLinks, drawerLinks].forEach(function(links) {
881
+ links.forEach(function(l) {
882
+ if (l.getAttribute('href').slice(1) === currentId) {
883
+ l.classList.add('active');
884
+ } else {
885
+ l.classList.remove('active');
886
+ }
887
+ });
888
+ });
889
+ // Auto-scroll drawer to active link if drawer is open
890
+ var drawer = document.getElementById('toc-drawer');
891
+ if (drawer && drawer.classList.contains('open')) {
892
+ var activeLink = drawer.querySelector('a.active');
893
+ if (activeLink) {
894
+ activeLink.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
499
895
  }
500
896
  }
501
- tocLinks.forEach(function(l) { l.classList.remove('active'); });
502
- if (current) current.link.classList.add('active');
503
897
  }
504
898
 
505
899
  window.addEventListener('scroll', update, { passive: true });
506
900
  update();
901
+ window._tocScrollSpyUpdate = update;
507
902
  })();
903
+
904
+ // TOC drawer — swipe gestures and controls
905
+ (function() {
906
+ var drawer = document.getElementById('toc-drawer');
907
+ var overlay = document.getElementById('toc-overlay');
908
+ var fab = document.getElementById('toc-fab');
909
+ if (!drawer || !overlay) return;
910
+
911
+ var isOpen = false;
912
+ var touchStartX = 0;
913
+ var touchStartY = 0;
914
+ var touchCurrentX = 0;
915
+ var isDragging = false;
916
+
917
+ function openDrawer() {
918
+ if (isOpen) return;
919
+ isOpen = true;
920
+ overlay.style.display = 'block';
921
+ // Force reflow before adding class for transition
922
+ overlay.offsetHeight;
923
+ overlay.classList.add('open');
924
+ drawer.classList.add('open');
925
+ document.body.style.overflow = 'hidden';
926
+ if (fab) fab.classList.add('hidden');
927
+ if (window._tocScrollSpyUpdate) window._tocScrollSpyUpdate();
928
+ // Scroll drawer to active link
929
+ var activeLink = drawer.querySelector('a.active');
930
+ if (activeLink) {
931
+ setTimeout(function() {
932
+ activeLink.scrollIntoView({ block: 'center', behavior: 'smooth' });
933
+ }, 100);
934
+ }
935
+ }
936
+
937
+ function closeDrawer() {
938
+ if (!isOpen) return;
939
+ isOpen = false;
940
+ overlay.classList.remove('open');
941
+ drawer.classList.remove('open');
942
+ document.body.style.overflow = '';
943
+ if (fab) fab.classList.remove('hidden');
944
+ setTimeout(function() {
945
+ if (!isOpen) overlay.style.display = 'none';
946
+ }, 300);
947
+ }
948
+
949
+ // Close button
950
+ var closeBtn = document.getElementById('toc-drawer-close');
951
+ if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
952
+
953
+ // Overlay tap closes drawer
954
+ overlay.addEventListener('click', closeDrawer);
955
+
956
+ // FAB opens drawer
957
+ if (fab) fab.addEventListener('click', openDrawer);
958
+
959
+ // Heading links close drawer and navigate
960
+ drawer.querySelectorAll('a').forEach(function(link) {
961
+ link.addEventListener('click', function() {
962
+ closeDrawer();
963
+ });
964
+ });
965
+
966
+ // Swipe detection
967
+ document.addEventListener('touchstart', function(e) {
968
+ if (e.touches.length !== 1) return;
969
+ touchStartX = e.touches[0].clientX;
970
+ touchStartY = e.touches[0].clientY;
971
+ touchCurrentX = touchStartX;
972
+ isDragging = false;
973
+ }, { passive: true });
974
+
975
+ document.addEventListener('touchmove', function(e) {
976
+ if (e.touches.length !== 1) return;
977
+ touchCurrentX = e.touches[0].clientX;
978
+ var dx = touchCurrentX - touchStartX;
979
+ var dy = e.touches[0].clientY - touchStartY;
980
+
981
+ // Only consider horizontal swipes
982
+ if (!isDragging && Math.abs(dx) > 10 && Math.abs(dy) < Math.abs(dx)) {
983
+ isDragging = true;
984
+ }
985
+
986
+ // Interactive drag on drawer when open
987
+ if (isDragging && isOpen && dx > 0) {
988
+ drawer.style.transition = 'none';
989
+ drawer.style.transform = 'translateX(' + Math.min(dx, drawer.offsetWidth) + 'px)';
990
+ var progress = Math.min(dx / drawer.offsetWidth, 1);
991
+ overlay.style.opacity = 1 - progress;
992
+ }
993
+ }, { passive: true });
994
+
995
+ document.addEventListener('touchend', function(e) {
996
+ var dx = touchCurrentX - touchStartX;
997
+ var dy = Math.abs(e.changedTouches[0].clientY - touchStartY);
998
+
999
+ // Reset any inline styles from dragging
1000
+ drawer.style.transition = '';
1001
+ drawer.style.transform = '';
1002
+ overlay.style.opacity = '';
1003
+
1004
+ if (!isDragging) return;
1005
+ isDragging = false;
1006
+
1007
+ var threshold = 50;
1008
+ if (dy > 80) return; // Too vertical
1009
+
1010
+ if (!isOpen && dx < -threshold) {
1011
+ // Swipe left — open drawer
1012
+ openDrawer();
1013
+ } else if (isOpen && dx > threshold) {
1014
+ // Swipe right — close drawer
1015
+ closeDrawer();
1016
+ } else if (isOpen && dx > 0) {
1017
+ // Didn't swipe far enough — snap back open
1018
+ openDrawer();
1019
+ }
1020
+ }, { passive: true });
1021
+ })();
1022
+
1023
+ // Jump to search match from _find query param
1024
+ (function() {
1025
+ var content = document.querySelector('.md-content');
1026
+ if (!content) return;
1027
+
1028
+ var params = new URLSearchParams(location.search);
1029
+ var findText = params.get('_find');
1030
+ if (!findText) return;
1031
+
1032
+ // Mark that _find is active so scroll-restore doesn't override
1033
+ window._findActive = true;
1034
+
1035
+ // Strip markdown syntax to get plain text words for matching
1036
+ var stripped = findText
1037
+ .replace(/^#{1,6}\s+/, '') // heading markers
1038
+ .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') // bold/italic
1039
+ .replace(/_{1,3}([^_]+)_{1,3}/g, '$1')
1040
+ .replace(/~~([^~]+)~~/g, '$1') // strikethrough
1041
+ .replace(/`([^`]+)`/g, '$1') // inline code
1042
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links
1043
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images
1044
+ .trim();
1045
+
1046
+ // Extract significant words (3+ chars) for fuzzy matching
1047
+ var words = stripped.toLowerCase().split(/\s+/).filter(function(w) {
1048
+ return w.replace(/[^a-z0-9]/g, '').length >= 3;
1049
+ });
1050
+
1051
+ if (words.length === 0) {
1052
+ // Fallback: use whatever non-whitespace we have
1053
+ words = stripped.toLowerCase().split(/\s+/).filter(function(w) { return w.length > 0; });
1054
+ }
1055
+ if (words.length === 0) return;
1056
+
1057
+ // Walk block-level elements and score by how many words match
1058
+ var blocks = content.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote, pre, dt, dd');
1059
+ var bestEl = null;
1060
+ var bestScore = 0;
1061
+
1062
+ for (var i = 0; i < blocks.length; i++) {
1063
+ var text = blocks[i].textContent.toLowerCase();
1064
+ var score = 0;
1065
+ for (var j = 0; j < words.length; j++) {
1066
+ if (text.indexOf(words[j]) !== -1) score++;
1067
+ }
1068
+ if (score > bestScore) {
1069
+ bestScore = score;
1070
+ bestEl = blocks[i];
1071
+ }
1072
+ }
1073
+
1074
+ if (bestEl) {
1075
+ requestAnimationFrame(function() {
1076
+ // Scroll with offset so a line or two above is visible
1077
+ var rect = bestEl.getBoundingClientRect();
1078
+ var offset = Math.max(0, window.scrollY + rect.top - 120);
1079
+ window.scrollTo({ top: offset });
1080
+
1081
+ // Brief highlight flash
1082
+ bestEl.style.transition = 'background 0.3s';
1083
+ bestEl.style.background = 'rgba(212, 185, 106, 0.3)';
1084
+ bestEl.style.borderRadius = '4px';
1085
+ setTimeout(function() {
1086
+ bestEl.style.background = '';
1087
+ setTimeout(function() {
1088
+ bestEl.style.transition = '';
1089
+ bestEl.style.borderRadius = '';
1090
+ }, 300);
1091
+ }, 2000);
1092
+ });
1093
+ }
1094
+ })();
1095
+
1096
+ // Remember scroll position by nearest heading, persist in localStorage
1097
+ (function() {
1098
+ var content = document.querySelector('.md-content');
1099
+ if (!content) return;
1100
+
1101
+ var headings = content.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
1102
+ if (!headings.length) return;
1103
+
1104
+ var key = 'scroll:' + location.pathname;
1105
+
1106
+ // Restore saved heading position on load (skip if _find is active)
1107
+ var savedId = localStorage.getItem(key);
1108
+ if (savedId && !window._findActive) {
1109
+ var target = document.getElementById(savedId);
1110
+ if (target) {
1111
+ // Use requestAnimationFrame to ensure layout is settled
1112
+ requestAnimationFrame(function() {
1113
+ target.scrollIntoView();
1114
+ });
1115
+ }
1116
+ }
1117
+
1118
+ // Save current heading on scroll (debounced)
1119
+ var timer;
1120
+ window.addEventListener('scroll', function() {
1121
+ clearTimeout(timer);
1122
+ timer = setTimeout(function() {
1123
+ var scrollY = window.scrollY + 80;
1124
+ var current = null;
1125
+ for (var i = 0; i < headings.length; i++) {
1126
+ if (headings[i].offsetTop <= scrollY) {
1127
+ current = headings[i];
1128
+ }
1129
+ }
1130
+ if (current && current.id) {
1131
+ localStorage.setItem(key, current.id);
1132
+ }
1133
+ }, 200);
1134
+ }, { passive: true });
1135
+ })();
1136
+
508
1137
  </script>
509
1138
  </body>
510
1139
  </html>
data/views/markdown.erb CHANGED
@@ -1,4 +1,10 @@
1
- <h1 class="page-title"><%= h(@title) %></h1>
1
+ <% _file_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(_file_path) %>" method="get">
5
+ <input type="text" name="q" placeholder="Search this document..." value="">
6
+ </form>
7
+ </div>
2
8
 
3
9
  <div class="toolbar">
4
10
  <a href="<%= @download_href %>">Download</a>
@@ -15,6 +21,22 @@
15
21
  <% end %>
16
22
  </ul>
17
23
  </details>
24
+
25
+ <button class="toc-fab" id="toc-fab" aria-label="Table of Contents">&#9776;</button>
26
+ <div class="toc-overlay" id="toc-overlay"></div>
27
+ <nav class="toc-drawer" id="toc-drawer">
28
+ <div class="toc-drawer-header">
29
+ <span class="toc-drawer-title">Contents</span>
30
+ <button class="toc-drawer-close" id="toc-drawer-close" aria-label="Close">&times;</button>
31
+ </div>
32
+ <ul>
33
+ <% @toc.each do |entry| %>
34
+ <li class="toc-h<%= entry[:level] %>">
35
+ <a href="#<%= h(entry[:id]) %>"><%= h(entry[:text]) %></a>
36
+ </li>
37
+ <% end %>
38
+ </ul>
39
+ </nav>
18
40
  <% end %>
19
41
 
20
42
  <div class="<%= @has_toc ? 'page-with-toc' : '' %>">
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
+
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,61 @@
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="<%= @is_file_search ? 'Search this document...' : 'Search files...' %>" value="<%= h(@query) %>">
5
+
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
+ <% elsif @is_file_search %>
16
+ <%= @results.first[:matches].length %> match<%= @results.first[:matches].length == 1 ? "" : "es" %> for "<%= h(@query) %>"
17
+ <% else %>
18
+ <%= @results.length %><%= @results.length >= 100 ? "+" : "" %> file<%= @results.length == 1 ? "" : "s" %> matching "<%= h(@query) %>"
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+
23
+ <% if @results.empty? && @error.nil? && !@query.empty? %>
24
+ <div class="search-no-results"><%= @is_file_search ? 'No matches found in this document.' : 'No matching files found.' %></div>
25
+ <% end %>
26
+
27
+ <% @results.each do |result| %>
28
+ <div class="search-result">
29
+ <% unless @is_file_search %>
30
+ <div class="search-result-path">
31
+ <span class="icon"><%= icon_for(File.basename(result[:path]), false) %></span>
32
+ <a href="/browse/<%= result[:path].split("/").map { |p| encode_path_component(p) }.join("/") %>"><%= h(result[:path]) %></a>
33
+ </div>
34
+ <% end %>
35
+ <% unless result[:matches].empty? %>
36
+ <% browse_href = "/browse/" + result[:path].split("/").map { |p| encode_path_component(p) }.join("/") %>
37
+ <div class="search-context">
38
+ <% result[:matches].each do |group| %>
39
+ <% match_line = group[:lines].find { |l| l[:distance] == 0 } %>
40
+ <% if match_line %>
41
+ <a class="search-context-group search-context-link" href="<%= browse_href %>?_find=<%= CGI.escape(match_line[:text]) %>">
42
+ <% else %>
43
+ <div class="search-context-group">
44
+ <% end %>
45
+ <% group[:lines].each do |line| %>
46
+ <% css_class = line[:distance] == 0 ? "search-line match-line" : "search-line ctx-#{line[:distance]}" %>
47
+ <div class="<%= css_class %>">
48
+ <span class="search-line-num"><%= line[:number] %></span>
49
+ <span class="search-line-text"><%= highlight_search_line(line[:text], @regexes, line[:distance] == 0) %></span>
50
+ </div>
51
+ <% end %>
52
+ <% if match_line %>
53
+ </a>
54
+ <% else %>
55
+ </div>
56
+ <% end %>
57
+ <% end %>
58
+ </div>
59
+ <% end %>
60
+ </div>
61
+ <% 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.3.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