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 +4 -4
- data/lib/markdown_server/app.rb +196 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/directory.erb +7 -1
- data/views/layout.erb +641 -12
- data/views/markdown.erb +23 -1
- data/views/raw.erb +8 -1
- data/views/search.erb +61 -0
- metadata +2 -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
|
@@ -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)
|
data/views/directory.erb
CHANGED
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
})();
|
|
16
16
|
</script>
|
|
17
17
|
|
|
18
|
-
<
|
|
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
|
-
|
|
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
|
|
484
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
874
|
+
var currentId = null;
|
|
496
875
|
for (var i = 0; i < headings.length; i++) {
|
|
497
876
|
if (headings[i].el.offsetTop <= scrollY) {
|
|
498
|
-
|
|
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
|
-
|
|
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">☰</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>
|
|
18
40
|
<% end %>
|
|
19
41
|
|
|
20
42
|
<div class="<%= @has_toc ? 'page-with-toc' : '' %>">
|
data/views/raw.erb
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|