markdownr 0.3.9 → 0.4.1

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: 7626abe88327e4f0878ec7263f7fd1d3df5bef105f9c32a7658292ec8e436561
4
- data.tar.gz: 0cd4fa7b7ca5275b2651f28b11e9d16dd51a152b69df59d97e58e1c40cde6374
3
+ metadata.gz: 6d6f02606d9b0949b45cb8fb62106b61ca22f7f9c10f3cf571c560d65bcca3d1
4
+ data.tar.gz: 0c0a37dddc07a3f079600fc4334e89e8cdd445335ef0ddaef6b953a12b5b3f75
5
5
  SHA512:
6
- metadata.gz: fdc6af6ff990a63e2aeb99eb0c37bf562849915e7406f313009f6a057665a5e601fe6b22c4241d28143b0a1b0bf60c2b849cfbc5b874f3158ea2a2cd82d02b6b
7
- data.tar.gz: cda8888fdf35ac9e80042b6063579f02d8b0243adb4493b543ca63a9e55da6e9c58b33460789c0d68e71006e6dc95b4218a3ac6c48642238da4e90f2fe8b36b5
6
+ metadata.gz: 8f3ed246b65e43417a330df814bfa1d0a773b6da193f80b707cbf75511032cf003479280c42a286386ce96ed423017244fccc3fd9ca201be4c88ad08cf84b899
7
+ data.tar.gz: 04404ec7014261c1e4dc3c6b86976519f99ee02b955581b9052f42f92550764c830372ee10953425ccebc60c7dc9276a77d5f2d7f9f863116f024a8e59da3030
data/bin/markdownr CHANGED
@@ -23,6 +23,10 @@ OptionParser.new do |opts|
23
23
  options[:allow_robots] = true
24
24
  end
25
25
 
26
+ opts.on("--no-link-tooltips", "Disable preview tooltips for local markdown links") do
27
+ options[:link_tooltips] = false
28
+ end
29
+
26
30
  opts.on("-v", "--version", "Show version") do
27
31
  puts "markdownr #{MarkdownServer::VERSION}"
28
32
  exit
@@ -40,6 +44,7 @@ end
40
44
  MarkdownServer::App.set :root_dir, dir
41
45
  MarkdownServer::App.set :custom_title, options[:title]
42
46
  MarkdownServer::App.set :allow_robots, options[:allow_robots] || false
47
+ MarkdownServer::App.set :link_tooltips, options.fetch(:link_tooltips, true)
43
48
  MarkdownServer::App.set :port, options[:port]
44
49
  MarkdownServer::App.set :bind, options[:bind]
45
50
 
@@ -19,6 +19,7 @@ module MarkdownServer
19
19
  set :root_dir, Dir.pwd
20
20
  set :custom_title, nil
21
21
  set :allow_robots, false
22
+ set :link_tooltips, true
22
23
  set :show_exceptions, false
23
24
  set :protection, false
24
25
  set :host_authorization, { permitted_hosts: [] }
@@ -430,6 +431,7 @@ module MarkdownServer
430
431
  rescue RegexpError => e
431
432
  raise RegexpError, e.message
432
433
  end
434
+
433
435
  end
434
436
 
435
437
  # Routes
@@ -472,6 +474,41 @@ module MarkdownServer
472
474
  send_file real_path, disposition: "attachment"
473
475
  end
474
476
 
477
+ get "/preview/*" do
478
+ content_type :json
479
+ halt 404, '{"error":"disabled"}' unless settings.link_tooltips
480
+
481
+ requested = params["splat"].first.to_s
482
+ base = File.realpath(root_dir)
483
+ full = File.join(base, requested)
484
+
485
+ begin
486
+ real = File.realpath(full)
487
+ rescue Errno::ENOENT
488
+ halt 404, '{"error":"not found"}'
489
+ end
490
+
491
+ halt 403, '{"error":"forbidden"}' unless real.start_with?(base)
492
+
493
+ relative = real.sub("#{base}/", "")
494
+ first_segment = relative.split("/").first
495
+ halt 403, '{"error":"forbidden"}' if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
496
+
497
+ halt 404, '{"error":"not found"}' unless File.file?(real) && File.extname(real).downcase == ".md"
498
+
499
+ begin
500
+ content = File.read(real, encoding: "utf-8")
501
+ rescue
502
+ halt 404, '{"error":"not found"}'
503
+ end
504
+
505
+ meta, body = parse_frontmatter(content)
506
+ title = (meta.is_a?(Hash) && meta["title"]) || File.basename(real, ".md")
507
+ html = render_markdown(body)
508
+
509
+ JSON.dump({ title: title.to_s, html: html })
510
+ end
511
+
475
512
  get "/search/?*" do
476
513
  requested = params["splat"].first.to_s.chomp("/")
477
514
  @query = params[:q].to_s.strip
@@ -532,9 +569,9 @@ module MarkdownServer
532
569
  }
533
570
  end.compact
534
571
 
535
- @sort = %w[name mtime ctime].include?(params[:sort]) ? params[:sort] : "mtime"
572
+ @sort = %w[name mtime ctime size].include?(params[:sort]) ? params[:sort] : "mtime"
536
573
  @order = %w[asc desc].include?(params[:order]) ? params[:order] : nil
537
- # Default order: name=asc, times=desc
574
+ # Default order: name=asc, size=desc, times=desc
538
575
  effective_order = @order || (@sort == "name" ? "asc" : "desc")
539
576
  @order_display = effective_order
540
577
 
@@ -542,6 +579,7 @@ module MarkdownServer
542
579
  sorted = case @sort
543
580
  when "name" then list.sort_by { |i| i[:name].downcase }
544
581
  when "ctime" then list.sort_by { |i| i[:ctime].to_f }
582
+ when "size" then list.sort_by { |i| i[:size] || -1 }
545
583
  else list.sort_by { |i| i[:mtime].to_f }
546
584
  end
547
585
  effective_order == "desc" ? sorted.reverse : sorted
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.3.9"
2
+ VERSION = "0.4.1"
3
3
  end
data/views/directory.erb CHANGED
@@ -33,6 +33,7 @@
33
33
  ["name", "Name"],
34
34
  ["mtime", "Modified"],
35
35
  ["ctime", "Created"],
36
+ ["size", "Size"],
36
37
  ]
37
38
  base = request.path
38
39
  %>
data/views/layout.erb CHANGED
@@ -678,6 +678,87 @@
678
678
  /* Footnote list — hide numeric markers; labels are inline via <strong> */
679
679
  .footnotes ol { list-style: none; padding-left: 1.2em; }
680
680
 
681
+ /* Link preview popup */
682
+ .link-tooltip-anchor { position: relative; }
683
+ .link-preview-popup {
684
+ position: absolute;
685
+ bottom: 100%;
686
+ left: 0;
687
+ width: 460px;
688
+ max-width: 90vw;
689
+ max-height: 55vh;
690
+ overflow-y: auto;
691
+ z-index: 150;
692
+ background: #faf8f4;
693
+ border: 1px solid #d4b96a;
694
+ border-radius: 6px;
695
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
696
+ padding: 0.8rem 1rem;
697
+ font-family: Georgia, "Times New Roman", serif;
698
+ font-size: 0.85rem;
699
+ line-height: 1.6;
700
+ color: #2c2c2c;
701
+ cursor: auto;
702
+ -webkit-overflow-scrolling: touch;
703
+ }
704
+ .link-preview-popup-title {
705
+ font-weight: 700;
706
+ font-size: 0.95rem;
707
+ margin-bottom: 0.6rem;
708
+ padding-bottom: 0.5rem;
709
+ border-bottom: 1px solid #e0d8c8;
710
+ color: #3a3a3a;
711
+ }
712
+ .link-preview-popup h1, .link-preview-popup h2, .link-preview-popup h3,
713
+ .link-preview-popup h4, .link-preview-popup h5, .link-preview-popup h6 {
714
+ margin: 0.7rem 0 0.3rem;
715
+ color: #3a3a3a;
716
+ }
717
+ .link-preview-popup h1 { font-size: 1.2rem; }
718
+ .link-preview-popup h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
719
+ .link-preview-popup h3 { font-size: 0.95rem; }
720
+ .link-preview-popup p { margin: 0 0 0.5rem; }
721
+ .link-preview-popup p:last-child { margin-bottom: 0; }
722
+ .link-preview-popup a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
723
+ .link-preview-popup a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
724
+ .link-preview-popup code {
725
+ font-family: "SF Mono", Menlo, Consolas, monospace;
726
+ font-size: 0.82em;
727
+ background: #f0ece3;
728
+ padding: 0.1em 0.3em;
729
+ border-radius: 3px;
730
+ }
731
+ .link-preview-popup pre {
732
+ background: #2d2d2d;
733
+ color: #f0f0f0;
734
+ padding: 0.6rem 0.8rem;
735
+ border-radius: 4px;
736
+ font-size: 0.78rem;
737
+ line-height: 1.4;
738
+ overflow-x: auto;
739
+ margin: 0.4rem 0;
740
+ }
741
+ .link-preview-popup pre code { background: none; padding: 0; font-size: 1em; }
742
+ .link-preview-popup ul, .link-preview-popup ol { padding-left: 1.4rem; margin: 0.3rem 0; }
743
+ .link-preview-popup li { margin-bottom: 0.2rem; }
744
+ .link-preview-popup blockquote {
745
+ border-left: 3px solid #d4b96a;
746
+ margin: 0.5rem 0;
747
+ padding: 0.3rem 0.8rem;
748
+ color: #4a4a4a;
749
+ font-style: italic;
750
+ }
751
+ .link-preview-popup table {
752
+ border-collapse: collapse;
753
+ font-size: 0.8rem;
754
+ margin: 0.5rem 0;
755
+ }
756
+ .link-preview-popup th, .link-preview-popup td {
757
+ border: 1px solid #ddd;
758
+ padding: 0.3rem 0.5rem;
759
+ }
760
+ .link-preview-popup th { background: #f5f0e4; }
761
+
681
762
  /* Footnote tooltips */
682
763
  .footnote-tooltip {
683
764
  position: absolute;
@@ -1241,6 +1322,135 @@
1241
1322
  }, { passive: true });
1242
1323
  })();
1243
1324
 
1325
+ <% if settings.link_tooltips %>
1326
+ // Link preview popup for local markdown links
1327
+ (function() {
1328
+ var content = document.querySelector('.md-content');
1329
+ if (!content) return;
1330
+
1331
+ var cache = Object.create(null);
1332
+ var activePopup = null;
1333
+ var activeAnchor = null;
1334
+ var hideTimer = null;
1335
+
1336
+ function escHtml(s) {
1337
+ return String(s)
1338
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
1339
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1340
+ }
1341
+
1342
+ function isLocalMdLink(a) {
1343
+ if (a.classList.contains('broken')) return false;
1344
+ var href = a.getAttribute('href');
1345
+ if (!href) return false;
1346
+ if (/^https?:\/\//i.test(href)) return false;
1347
+ var path = href.split('?')[0].split('#')[0];
1348
+ return /\.md$/i.test(path);
1349
+ }
1350
+
1351
+ function previewUrl(a) {
1352
+ var resolved = new URL(a.getAttribute('href'), location.href);
1353
+ return '/preview/' + resolved.pathname.replace(/^\/browse\//, '');
1354
+ }
1355
+
1356
+ function showPopup(anchor, html) {
1357
+ hidePopup();
1358
+ var pop = document.createElement('div');
1359
+ pop.className = 'link-preview-popup';
1360
+ pop.innerHTML = html;
1361
+ anchor.appendChild(pop);
1362
+ activePopup = pop;
1363
+ activeAnchor = anchor;
1364
+
1365
+ // Clamp right edge to viewport
1366
+ var vw = Math.min(window.innerWidth, document.documentElement.clientWidth);
1367
+ var rect = pop.getBoundingClientRect();
1368
+ if (rect.right > vw - 8) {
1369
+ pop.style.left = Math.max(0, parseFloat(pop.style.left || 0) - (rect.right - (vw - 8))) + 'px';
1370
+ }
1371
+ // If top goes off screen, flip below the link instead
1372
+ rect = pop.getBoundingClientRect();
1373
+ if (rect.top < 8) {
1374
+ pop.style.bottom = 'auto';
1375
+ pop.style.top = '100%';
1376
+ }
1377
+
1378
+ // Keep popup open while hovering over it
1379
+ pop.addEventListener('mouseenter', function() { clearTimeout(hideTimer); });
1380
+ pop.addEventListener('mouseleave', function() { hideTimer = setTimeout(hidePopup, 10); });
1381
+
1382
+ // Stop clicks/touches inside the popup from triggering the outer link
1383
+ pop.addEventListener('click', function(e) {
1384
+ e.stopPropagation();
1385
+ if (!e.target.closest('a')) e.preventDefault();
1386
+ });
1387
+ pop.addEventListener('touchstart', function(e) { e.stopPropagation(); }, { passive: true });
1388
+ pop.addEventListener('touchmove', function(e) { e.stopPropagation(); }, { passive: true });
1389
+ pop.addEventListener('touchend', function(e) {
1390
+ e.stopPropagation();
1391
+ if (!e.target.closest('a')) e.preventDefault();
1392
+ });
1393
+ }
1394
+
1395
+ function hidePopup() {
1396
+ if (activePopup && activePopup.parentNode) activePopup.parentNode.removeChild(activePopup);
1397
+ activePopup = null;
1398
+ activeAnchor = null;
1399
+ }
1400
+
1401
+ function fetchAndShow(anchor) {
1402
+ var url = previewUrl(anchor);
1403
+ var cached = cache[url];
1404
+ if (cached === undefined) {
1405
+ cache[url] = null; // in-flight
1406
+ showPopup(anchor, '<p style="opacity:0.5;margin:0">Loading\u2026</p>');
1407
+ fetch(url)
1408
+ .then(function(r) { return r.ok ? r.json() : null; })
1409
+ .then(function(data) {
1410
+ if (!data) { cache[url] = false; if (activeAnchor === anchor) hidePopup(); return; }
1411
+ var html = '<div class="link-preview-popup-title">' + escHtml(data.title) + '</div>' + data.html;
1412
+ cache[url] = html;
1413
+ if (activeAnchor === anchor) showPopup(anchor, html);
1414
+ })
1415
+ .catch(function() { cache[url] = false; if (activeAnchor === anchor) hidePopup(); });
1416
+ } else if (typeof cached === 'string') {
1417
+ showPopup(anchor, cached);
1418
+ }
1419
+ }
1420
+
1421
+ content.querySelectorAll('a').forEach(function(a) {
1422
+ if (!isLocalMdLink(a)) return;
1423
+ a.classList.add('link-tooltip-anchor');
1424
+
1425
+ var hoverTimer = null;
1426
+ a.addEventListener('mouseenter', function() {
1427
+ hoverTimer = setTimeout(function() { fetchAndShow(a); }, 300);
1428
+ });
1429
+ a.addEventListener('mouseleave', function() {
1430
+ clearTimeout(hoverTimer);
1431
+ hideTimer = setTimeout(hidePopup, 10);
1432
+ });
1433
+
1434
+ var touchMoved = false;
1435
+ a.addEventListener('touchstart', function() { touchMoved = false; }, { passive: true });
1436
+ a.addEventListener('touchmove', function() { touchMoved = true; }, { passive: true });
1437
+ a.addEventListener('touchend', function(e) {
1438
+ if (touchMoved) return;
1439
+ if (activeAnchor === a && activePopup) return; // second tap → navigate
1440
+ e.preventDefault();
1441
+ document.querySelectorAll('.link-preview-popup').forEach(function(p) {
1442
+ if (p.parentNode) p.parentNode.removeChild(p);
1443
+ });
1444
+ fetchAndShow(a);
1445
+ });
1446
+ });
1447
+
1448
+ document.addEventListener('click', function(e) {
1449
+ if (activePopup && activeAnchor && !activeAnchor.contains(e.target)) hidePopup();
1450
+ });
1451
+ })();
1452
+ <% end %>
1453
+
1244
1454
  </script>
1245
1455
  </body>
1246
1456
  </html>
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.3.9
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn