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 +4 -4
- data/lib/markdown_server/app.rb +25 -4
- data/lib/markdown_server/version.rb +1 -1
- data/views/directory.erb +1 -1
- data/views/layout.erb +500 -29
- data/views/markdown.erb +19 -4
- data/views/raw.erb +1 -1
- data/views/search.erb +23 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aca98df210e2ff9be2ee3291855c8fbff33567421de36ac4f8ae384a72ba852e
|
|
4
|
+
data.tar.gz: bdf70db5b97981433249badd64f7203fda7abf701d5507661248d755683244c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4b70939c23cbc36194e4471f4f60e34b0110bfcca689542ced01df2c2f19ed3b928c84a9232f44180ff6e70b4c3d82317ec96d8dd2eb2202c7d1fcbbdd9eb14
|
|
7
|
+
data.tar.gz: 6cabae16ae4ca30e35e2a0e66f9d64794aa1c28a7afac14cead2d529ffac67c07ebf0b8016eb225a3ea5d09e8f16e3b303068be66153f74e856e36e80c1474dc
|
data/lib/markdown_server/app.rb
CHANGED
|
@@ -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
|
-
|
|
391
|
+
search_path = File.realpath(root_dir)
|
|
392
|
+
@is_file_search = false
|
|
379
393
|
else
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
642
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
874
|
+
var currentId = null;
|
|
654
875
|
for (var i = 0; i < headings.length; i++) {
|
|
655
876
|
if (headings[i].el.offsetTop <= scrollY) {
|
|
656
|
-
|
|
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
|
-
<%
|
|
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(
|
|
5
|
-
<input type="text" name="q" placeholder="Search
|
|
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">☰</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">×</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
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
|
-
|
|
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"
|
|
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
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
<% if match_line %>
|
|
53
|
+
</a>
|
|
54
|
+
<% else %>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
43
57
|
<% end %>
|
|
44
58
|
</div>
|
|
45
59
|
<% end %>
|