markdownr 0.2.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: eb86e2f05b5c71f9365fba3fdcf497f1c53439e7b395d13efb674eb36947f3dc
4
- data.tar.gz: 3c2d3da0044862746e47d76c386712b8ab8a0f6c1a7c60514e511953e2af5542
3
+ metadata.gz: aca98df210e2ff9be2ee3291855c8fbff33567421de36ac4f8ae384a72ba852e
4
+ data.tar.gz: bdf70db5b97981433249badd64f7203fda7abf701d5507661248d755683244c0
5
5
  SHA512:
6
- metadata.gz: 1aa636326b55bbff250fa04e13f5befadda9791fbabdf175c7c774886d8df793ee4e1ecc93d59a3bc01428eaa26e2c104ab6b831dd0e1323666e970fd64be634
7
- data.tar.gz: 1ad0d23592aa3874b519cfa3afbfc310bc0fd12c90c223a54bf4a530e1e7ca7da77d1c00a548210fde5a043ffad2f52460f40a2989cb30b2f900db666ad3fac1
6
+ metadata.gz: b4b70939c23cbc36194e4471f4f60e34b0110bfcca689542ced01df2c2f19ed3b928c84a9232f44180ff6e70b4c3d82317ec96d8dd2eb2202c7d1fcbbdd9eb14
7
+ data.tar.gz: 6cabae16ae4ca30e35e2a0e66f9d64794aa1c28a7afac14cead2d529ffac67c07ebf0b8016eb225a3ea5d09e8f16e3b303068be66153f74e856e36e80c1474dc
@@ -219,6 +219,19 @@ module MarkdownServer
219
219
  CONTEXT_LINES = 2 # lines before/after match to send
220
220
  MAX_LINE_DISPLAY = 200 # chars before truncating a line
221
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
+
222
235
  def search_files(dir_path, regexes)
223
236
  results = []
224
237
  base = File.realpath(root_dir)
@@ -375,10 +388,12 @@ module MarkdownServer
375
388
  @query = params[:q].to_s.strip
376
389
 
377
390
  if requested.empty?
378
- search_dir = File.realpath(root_dir)
391
+ search_path = File.realpath(root_dir)
392
+ @is_file_search = false
379
393
  else
380
- search_dir = safe_path(requested)
381
- halt 404 unless File.directory?(search_dir)
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)
382
397
  end
383
398
 
384
399
  @path = requested
@@ -391,7 +406,13 @@ module MarkdownServer
391
406
  unless @query.empty?
392
407
  begin
393
408
  @regexes = compile_regexes(@query)
394
- @results = search_files(search_dir, @regexes) if @regexes
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
395
416
  rescue RegexpError => e
396
417
  @error = "Invalid regex: #{e.message}"
397
418
  end
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/views/directory.erb CHANGED
@@ -19,7 +19,7 @@
19
19
  <h1 class="page-title"><%= h(@title) %></h1>
20
20
  <form class="search-form" action="<%= search_form_path(@path) %>" method="get">
21
21
  <input type="text" name="q" placeholder="Search files..." value="<%= h(params[:q].to_s) %>">
22
- <button type="submit">Search</button>
22
+
23
23
  </form>
24
24
  </div>
25
25
 
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,6 +437,137 @@
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
+
419
571
  /* Title bar with search */
420
572
  .title-bar {
421
573
  display: flex;
@@ -441,7 +593,7 @@
441
593
  font-size: 0.82rem;
442
594
  padding: 0.3rem 0.6rem;
443
595
  border: 1px solid #d4b96a;
444
- border-radius: 4px 0 0 4px;
596
+ border-radius: 4px;
445
597
  background: #fdfcf9;
446
598
  color: #2c2c2c;
447
599
  width: 180px;
@@ -454,22 +606,6 @@
454
606
  .search-form input[type="text"]::placeholder {
455
607
  color: #bbb;
456
608
  }
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
609
  /* Search results */
474
610
  .search-summary {
475
611
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
@@ -516,10 +652,20 @@
516
652
  }
517
653
  .search-context-group {
518
654
  padding: 0.4rem 0;
655
+ display: block;
519
656
  }
520
657
  .search-context-group + .search-context-group {
521
658
  border-top: 1px dashed #555;
522
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
+ }
523
669
  .search-line {
524
670
  display: flex;
525
671
  padding: 0 0.8rem;
@@ -601,7 +747,7 @@
601
747
 
602
748
  @media (max-width: 480px) {
603
749
  .container { padding: 0.8rem; }
604
- .breadcrumbs { font-size: 0.8rem; }
750
+ .breadcrumbs { font-size: 0.8rem; padding: 0.5rem 0.8rem; }
605
751
  .md-content { font-size: 0.95rem; }
606
752
  .md-content table { font-size: 0.8rem; }
607
753
  }
@@ -614,18 +760,91 @@
614
760
  <% @crumbs.each_with_index do |crumb, i| %>
615
761
  <% if i > 0 %><span class="sep">/</span><% end %>
616
762
  <% if i == @crumbs.length - 1 %>
617
- <%= h(crumb[:name]) %>
763
+ <% if @download_href %><a href="#"><%= h(crumb[:name]) %></a><% else %><%= h(crumb[:name]) %><% end %>
618
764
  <% else %>
619
765
  <a href="<%= crumb[:href] %>"><%= h(crumb[:name]) %></a>
620
766
  <% end %>
621
767
  <% end %>
622
768
  </nav>
769
+ <div class="breadcrumb-spacer"></div>
623
770
  <% end %>
624
771
 
625
772
  <%= yield %>
626
773
  </div>
627
774
 
628
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
+
629
848
  // Wrap tables in scrollable containers
630
849
  document.querySelectorAll('.md-content table').forEach(function(table) {
631
850
  if (!table.parentElement.classList.contains('table-wrap')) {
@@ -636,33 +855,285 @@
636
855
  }
637
856
  });
638
857
 
639
- // TOC scroll spy — highlight the nearest heading
858
+ // TOC scroll spy — highlight the nearest heading (sidebar + drawer)
640
859
  (function() {
641
- var tocLinks = document.querySelectorAll('.toc-sidebar a');
642
- 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;
643
863
 
864
+ var refLinks = sidebarLinks.length ? sidebarLinks : drawerLinks;
644
865
  var headings = [];
645
- tocLinks.forEach(function(link) {
866
+ refLinks.forEach(function(link) {
646
867
  var id = link.getAttribute('href').slice(1);
647
868
  var el = document.getElementById(id);
648
- if (el) headings.push({ el: el, link: link });
869
+ if (el) headings.push({ el: el, id: id });
649
870
  });
650
871
 
651
872
  function update() {
652
873
  var scrollY = window.scrollY + 80;
653
- var current = null;
874
+ var currentId = null;
654
875
  for (var i = 0; i < headings.length; i++) {
655
876
  if (headings[i].el.offsetTop <= scrollY) {
656
- 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' });
657
895
  }
658
896
  }
659
- tocLinks.forEach(function(l) { l.classList.remove('active'); });
660
- if (current) current.link.classList.add('active');
661
897
  }
662
898
 
663
899
  window.addEventListener('scroll', update, { passive: true });
664
900
  update();
901
+ window._tocScrollSpyUpdate = update;
665
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
+
666
1137
  </script>
667
1138
  </body>
668
1139
  </html>
data/views/markdown.erb CHANGED
@@ -1,9 +1,8 @@
1
- <% _parent = parent_dir_path(@crumbs.map { |c| c[:name] }.drop(1).join("/")) %>
1
+ <% _file_path = @crumbs.map { |c| c[:name] }.drop(1).join("/") %>
2
2
  <div class="title-bar">
3
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>
4
+ <form class="search-form" action="<%= search_form_path(_file_path) %>" method="get">
5
+ <input type="text" name="q" placeholder="Search this document..." value="">
7
6
  </form>
8
7
  </div>
9
8
 
@@ -22,6 +21,22 @@
22
21
  <% end %>
23
22
  </ul>
24
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>
25
40
  <% end %>
26
41
 
27
42
  <div class="<%= @has_toc ? 'page-with-toc' : '' %>">
data/views/raw.erb CHANGED
@@ -3,7 +3,7 @@
3
3
  <h1 class="page-title"><%= h(@title) %></h1>
4
4
  <form class="search-form" action="<%= search_form_path(_parent) %>" method="get">
5
5
  <input type="text" name="q" placeholder="Search files..." value="">
6
- <button type="submit">Search</button>
6
+
7
7
  </form>
8
8
  </div>
9
9
 
data/views/search.erb CHANGED
@@ -1,8 +1,8 @@
1
1
  <div class="title-bar">
2
2
  <h1 class="page-title">Search: <%= h(@title) %></h1>
3
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>
4
+ <input type="text" name="q" placeholder="<%= @is_file_search ? 'Search this document...' : 'Search files...' %>" value="<%= h(@query) %>">
5
+
6
6
  </form>
7
7
  </div>
8
8
 
@@ -12,6 +12,8 @@
12
12
  <div class="search-summary">
13
13
  <% if @results.empty? %>
14
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) %>"
15
17
  <% else %>
16
18
  <%= @results.length %><%= @results.length >= 100 ? "+" : "" %> file<%= @results.length == 1 ? "" : "s" %> matching "<%= h(@query) %>"
17
19
  <% end %>
@@ -19,19 +21,27 @@
19
21
  <% end %>
20
22
 
21
23
  <% if @results.empty? && @error.nil? && !@query.empty? %>
22
- <div class="search-no-results">No matching files found.</div>
24
+ <div class="search-no-results"><%= @is_file_search ? 'No matches found in this document.' : 'No matching files found.' %></div>
23
25
  <% end %>
24
26
 
25
27
  <% @results.each do |result| %>
26
28
  <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>
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 %>
31
35
  <% unless result[:matches].empty? %>
36
+ <% browse_href = "/browse/" + result[:path].split("/").map { |p| encode_path_component(p) }.join("/") %>
32
37
  <div class="search-context">
33
38
  <% result[:matches].each do |group| %>
34
- <div class="search-context-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 %>
35
45
  <% group[:lines].each do |line| %>
36
46
  <% css_class = line[:distance] == 0 ? "search-line match-line" : "search-line ctx-#{line[:distance]}" %>
37
47
  <div class="<%= css_class %>">
@@ -39,7 +49,11 @@
39
49
  <span class="search-line-text"><%= highlight_search_line(line[:text], @regexes, line[:distance] == 0) %></span>
40
50
  </div>
41
51
  <% end %>
42
- </div>
52
+ <% if match_line %>
53
+ </a>
54
+ <% else %>
55
+ </div>
56
+ <% end %>
43
57
  <% end %>
44
58
  </div>
45
59
  <% 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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn