markdownr 0.4.4 → 0.4.6

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: 6e4ee7e59fec1befc24c65b8943b4e88867464c25d49c0b4852c29641e904606
4
- data.tar.gz: b0fb71dd07e4116c8650e475f4ea045d976ba327db9bd3bf79cc7ec550418cdd
3
+ metadata.gz: 596b75e231efdc839c8729fe1db59a8b2933c08f12059cf753b261445d888813
4
+ data.tar.gz: ddc7fc42eb7f55286e0edcb296ec816567146e1ad412ae4df4243ab31255e4ea
5
5
  SHA512:
6
- metadata.gz: c892d119b73cce17d1b066e3d9ea621922d084d6d6577af6b75cd186d9a3d5b56a6579d2b14967827c07d5335855a04a55e4260347ba97b03535f3500eac1716
7
- data.tar.gz: 1072d404d1b459932afcb3fab9b7d8bea43a517ece76e3b2f96d64d8ecd356de59658f0d67063b239b7e0cb3d8a965463e6c06c02a677b878dc4bdd8ce6f1167
6
+ metadata.gz: 34a2585d6a3818cd175d40f8b9f728bee1a83b3dbf2ac238c320b8b2be9d49c895dc9fb9709edfeed149317d4a03caa1536a87075da706711ceafa61a3c789d9
7
+ data.tar.gz: 256c941d8ad83a87fca9d205be075390c425c3b883dbd7fc3b2ed4f71e0f61c6cc29517a56897046c9c2a9c4507978b84445c9edeaf456e054527e9be1ed0f00
@@ -8,6 +8,7 @@ require "uri"
8
8
  require "cgi"
9
9
  require "pathname"
10
10
  require "set"
11
+ require "net/http"
11
12
 
12
13
  module MarkdownServer
13
14
  class App < Sinatra::Base
@@ -431,6 +432,76 @@ module MarkdownServer
431
432
  html
432
433
  end
433
434
 
435
+ FETCH_MAX_BYTES = 512_000
436
+ FETCH_TIMEOUT = 5
437
+
438
+ def fetch_external_page(url_str)
439
+ uri = URI.parse(url_str)
440
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
441
+ fetch_follow_redirects(uri, 5)
442
+ rescue
443
+ nil
444
+ end
445
+
446
+ def fetch_follow_redirects(uri, limit)
447
+ return nil if limit <= 0
448
+ http = Net::HTTP.new(uri.host, uri.port)
449
+ http.use_ssl = (uri.scheme == "https")
450
+ http.open_timeout = FETCH_TIMEOUT
451
+ http.read_timeout = FETCH_TIMEOUT
452
+ req = Net::HTTP::Get.new(uri.request_uri)
453
+ req["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
454
+ req["Accept"] = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8"
455
+ req["Accept-Language"] = "en-US,en;q=0.5"
456
+ resp = http.request(req)
457
+ case resp
458
+ when Net::HTTPSuccess
459
+ ct = resp["content-type"].to_s
460
+ return nil unless ct.match?(/html|text/i)
461
+ body = resp.body.to_s
462
+ body = body.b[0, FETCH_MAX_BYTES].force_encoding("utf-8")
463
+ body.encode("utf-8", invalid: :replace, undef: :replace, replace: "?")
464
+ when Net::HTTPRedirection
465
+ loc = resp["Location"].to_s
466
+ new_uri = (URI.parse(loc) rescue nil)
467
+ return nil unless new_uri
468
+ new_uri = uri + new_uri unless new_uri.absolute?
469
+ return nil unless new_uri.is_a?(URI::HTTP) || new_uri.is_a?(URI::HTTPS)
470
+ fetch_follow_redirects(new_uri, limit - 1)
471
+ end
472
+ rescue
473
+ nil
474
+ end
475
+
476
+ def page_title(html)
477
+ html.match(/<title[^>]*>(.*?)<\/title>/im)&.then { |m|
478
+ m[1].gsub(/<[^>]+>/, "").gsub(/&amp;/i, "&").gsub(/&lt;/i, "<")
479
+ .gsub(/&gt;/i, ">").gsub(/&quot;/i, '"').gsub(/&#?\w+;/, "").strip
480
+ } || ""
481
+ end
482
+
483
+ def page_text(html)
484
+ # Strip inert elements
485
+ w = html
486
+ .gsub(/<script[^>]*>.*?<\/script>/im, " ")
487
+ .gsub(/<style[^>]*>.*?<\/style>/im, " ")
488
+ .gsub(/<nav[^>]*>.*?<\/nav>/im, " ")
489
+ .gsub(/<header[^>]*>.*?<\/header>/im," ")
490
+ .gsub(/<footer[^>]*>.*?<\/footer>/im," ")
491
+ .gsub(/<!--.*?-->/m, " ")
492
+ # Prefer <article> or <main>, fall back to <body>, then whole doc
493
+ content = w.match(/<article[^>]*>(.*?)<\/article>/im)&.[](1) ||
494
+ w.match(/<main[^>]*>(.*?)<\/main>/im)&.[](1) ||
495
+ w.match(/<body[^>]*>(.*?)<\/body>/im)&.[](1) ||
496
+ w
497
+ text = content
498
+ .gsub(/<[^>]+>/, " ")
499
+ .gsub(/&nbsp;/i, " ").gsub(/&amp;/i, "&").gsub(/&lt;/i, "<")
500
+ .gsub(/&gt;/i, ">").gsub(/&quot;/i, '"').gsub(/&#?\w+;/, " ")
501
+ .gsub(/\s+/, " ").strip
502
+ text.length > 3000 ? "#{text[0, 3000]}…" : text
503
+ end
504
+
434
505
  def compile_regexes(query)
435
506
  words = query.split(/\s+/).reject(&:empty?)
436
507
  return nil if words.empty?
@@ -483,8 +554,6 @@ module MarkdownServer
483
554
 
484
555
  get "/preview/*" do
485
556
  content_type :json
486
- halt 404, '{"error":"disabled"}' unless settings.link_tooltips
487
-
488
557
  requested = params["splat"].first.to_s
489
558
  base = File.realpath(root_dir)
490
559
  full = File.join(base, requested)
@@ -516,6 +585,17 @@ module MarkdownServer
516
585
  JSON.dump({ title: title.to_s, html: html })
517
586
  end
518
587
 
588
+ get "/fetch" do
589
+ content_type :json
590
+ url = params[:url].to_s.strip
591
+ halt 400, '{"error":"invalid url"}' unless url.match?(/\Ahttps?:\/\//i)
592
+
593
+ html = fetch_external_page(url)
594
+ halt 502, '{"error":"fetch failed"}' unless html
595
+
596
+ JSON.dump({ title: page_title(html), text: page_text(html) })
597
+ end
598
+
519
599
  get "/search/?*" do
520
600
  requested = params["splat"].first.to_s.chomp("/")
521
601
  @query = params[:q].to_s.strip
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.4.4"
2
+ VERSION = "0.4.6"
3
3
  end
data/views/layout.erb CHANGED
@@ -678,6 +678,111 @@
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
+ /* Right-click / long-press link context popup */
682
+ .link-ctx-popup {
683
+ position: fixed;
684
+ z-index: 500;
685
+ width: 460px;
686
+ max-width: calc(100vw - 16px);
687
+ max-height: 60vh;
688
+ overflow-y: auto;
689
+ background: #faf8f4;
690
+ border: 1px solid #d4b96a;
691
+ border-radius: 6px;
692
+ box-shadow: 0 4px 20px rgba(0,0,0,0.22);
693
+ -webkit-overflow-scrolling: touch;
694
+ cursor: auto;
695
+ }
696
+ .link-ctx-popup-header {
697
+ display: flex;
698
+ justify-content: space-between;
699
+ align-items: center;
700
+ gap: 0.5rem;
701
+ padding: 0.5rem 0.9rem;
702
+ border-bottom: 1px solid #e0d8c8;
703
+ position: sticky;
704
+ top: 0;
705
+ background: #faf8f4;
706
+ z-index: 1;
707
+ }
708
+ .link-ctx-popup-title {
709
+ font-family: Georgia, "Times New Roman", serif;
710
+ font-weight: 700;
711
+ font-size: 0.9rem;
712
+ color: #3a3a3a;
713
+ min-width: 0;
714
+ word-break: break-word;
715
+ }
716
+ .link-ctx-popup-close {
717
+ background: none;
718
+ border: none;
719
+ font-size: 1.2rem;
720
+ line-height: 1;
721
+ color: #888;
722
+ cursor: pointer;
723
+ padding: 0 0.2rem;
724
+ flex-shrink: 0;
725
+ }
726
+ .link-ctx-popup-close:hover { color: #2c2c2c; }
727
+ .link-ctx-popup-body {
728
+ padding: 0.75rem 1rem;
729
+ font-family: Georgia, "Times New Roman", serif;
730
+ font-size: 0.85rem;
731
+ line-height: 1.6;
732
+ color: #2c2c2c;
733
+ }
734
+ .link-ctx-popup-url {
735
+ font-family: "SF Mono", Menlo, Consolas, monospace;
736
+ font-size: 0.75rem;
737
+ color: #555;
738
+ word-break: break-all;
739
+ background: #f0ece3;
740
+ padding: 0.35rem 0.6rem;
741
+ border-radius: 4px;
742
+ margin-bottom: 0.5rem;
743
+ }
744
+ .link-ctx-popup-body h1, .link-ctx-popup-body h2, .link-ctx-popup-body h3,
745
+ .link-ctx-popup-body h4, .link-ctx-popup-body h5, .link-ctx-popup-body h6 {
746
+ margin: 0.7rem 0 0.3rem; color: #3a3a3a;
747
+ }
748
+ .link-ctx-popup-body h1 { font-size: 1.2rem; }
749
+ .link-ctx-popup-body h2 { font-size: 1.05rem; border-bottom: none; padding-bottom: 0; }
750
+ .link-ctx-popup-body h3 { font-size: 0.95rem; }
751
+ .link-ctx-popup-body p { margin: 0 0 0.5rem; }
752
+ .link-ctx-popup-body p:last-child { margin-bottom: 0; }
753
+ .link-ctx-popup-body a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
754
+ .link-ctx-popup-body a.wiki-link { color: #6a8e3e; border-bottom-color: #6a8e3e; }
755
+ .link-ctx-popup-body code {
756
+ font-family: "SF Mono", Menlo, Consolas, monospace;
757
+ font-size: 0.82em;
758
+ background: #f0ece3;
759
+ padding: 0.1em 0.3em;
760
+ border-radius: 3px;
761
+ }
762
+ .link-ctx-popup-body pre {
763
+ background: #2d2d2d;
764
+ color: #f0f0f0;
765
+ padding: 0.6rem 0.8rem;
766
+ border-radius: 4px;
767
+ font-size: 0.78rem;
768
+ line-height: 1.4;
769
+ overflow-x: auto;
770
+ margin: 0.4rem 0;
771
+ }
772
+ .link-ctx-popup-body pre code { background: none; padding: 0; font-size: 1em; color: inherit; }
773
+ .link-ctx-popup-body ul, .link-ctx-popup-body ol { padding-left: 1.4rem; margin: 0.3rem 0; }
774
+ .link-ctx-popup-body li { margin-bottom: 0.2rem; }
775
+ .link-ctx-popup-body blockquote {
776
+ border-left: 3px solid #d4b96a;
777
+ margin: 0.5rem 0;
778
+ padding: 0.3rem 0.8rem;
779
+ color: #4a4a4a;
780
+ font-style: italic;
781
+ }
782
+ .link-ctx-popup-body table { border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0; }
783
+ .link-ctx-popup-body th, .link-ctx-popup-body td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
784
+ .link-ctx-popup-body th { background: #f5f0e4; }
785
+
681
786
  /* Link preview popup */
682
787
  .link-tooltip-anchor { position: relative; }
683
788
  .link-preview-popup {
@@ -1322,6 +1427,220 @@
1322
1427
  }, { passive: true });
1323
1428
  })();
1324
1429
 
1430
+ // Right-click / long-press popup for all links
1431
+ (function() {
1432
+ var cache = Object.create(null);
1433
+ var popup = null;
1434
+ var longPressTimer = null;
1435
+ var touchMoved = false;
1436
+ var pendingTouch = null;
1437
+ var longPressHandled = false;
1438
+
1439
+ function escHtml(s) {
1440
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1441
+ }
1442
+
1443
+ function findLink(el) {
1444
+ while (el && el.tagName !== 'BODY') {
1445
+ if (el.tagName === 'A' && el.getAttribute('href')) return el;
1446
+ el = el.parentElement;
1447
+ }
1448
+ return null;
1449
+ }
1450
+
1451
+ function isAnchorOnly(href) { return href.charAt(0) === '#'; }
1452
+ function isLocalMd(href) {
1453
+ if (/^https?:\/\//i.test(href)) return false;
1454
+ return /\.md([?#]|$)/i.test(href);
1455
+ }
1456
+ function isExternal(href) { return /^https?:\/\//i.test(href); }
1457
+
1458
+ function previewPath(href) {
1459
+ var resolved = new URL(href, location.href);
1460
+ return '/preview/' + resolved.pathname.replace(/^\/browse\//, '');
1461
+ }
1462
+
1463
+ function showPopup(x, y, title, bodyHtml) {
1464
+ hidePopup();
1465
+ popup = document.createElement('div');
1466
+ popup.className = 'link-ctx-popup';
1467
+ popup.innerHTML =
1468
+ '<div class="link-ctx-popup-header">' +
1469
+ '<span class="link-ctx-popup-title">' + escHtml(title) + '</span>' +
1470
+ '<button class="link-ctx-popup-close" aria-label="Close">\u00d7</button>' +
1471
+ '</div>' +
1472
+ '<div class="link-ctx-popup-body">' + bodyHtml + '</div>';
1473
+ document.body.appendChild(popup);
1474
+
1475
+ // Position near pointer, clamped to viewport
1476
+ var vw = Math.min(window.innerWidth, document.documentElement.clientWidth);
1477
+ var vh = window.innerHeight;
1478
+ var left = x + 12;
1479
+ var top = y + 12;
1480
+ if (left + popup.offsetWidth > vw - 8) left = Math.max(8, vw - popup.offsetWidth - 8);
1481
+ if (top + popup.offsetHeight > vh - 8) top = Math.max(8, y - popup.offsetHeight - 12);
1482
+ if (top < 8) top = 8;
1483
+ popup.style.left = left + 'px';
1484
+ popup.style.top = top + 'px';
1485
+
1486
+ popup.querySelector('.link-ctx-popup-close').addEventListener('click', hidePopup);
1487
+ popup.addEventListener('click', function(e) { e.stopPropagation(); });
1488
+ popup.addEventListener('contextmenu', function(e) { e.preventDefault(); e.stopPropagation(); });
1489
+ ['touchstart','touchmove','touchend'].forEach(function(ev) {
1490
+ popup.addEventListener(ev, function(e) { e.stopPropagation(); }, { passive: true });
1491
+ });
1492
+ }
1493
+
1494
+ function updatePopup(bodyHtml, title) {
1495
+ if (!popup) return;
1496
+ var body = popup.querySelector('.link-ctx-popup-body');
1497
+ var titleEl = popup.querySelector('.link-ctx-popup-title');
1498
+ if (body) body.innerHTML = bodyHtml;
1499
+ if (title && titleEl) titleEl.textContent = title;
1500
+ }
1501
+
1502
+ function hidePopup() {
1503
+ if (popup && popup.parentNode) popup.parentNode.removeChild(popup);
1504
+ popup = null;
1505
+ }
1506
+
1507
+ function handleLink(anchor, x, y) {
1508
+ var href = anchor.getAttribute('href');
1509
+ if (!href || isAnchorOnly(href)) return;
1510
+ var label = anchor.textContent.trim() || href;
1511
+
1512
+ if (isLocalMd(href)) {
1513
+ var path = previewPath(href);
1514
+ var cached = cache[path];
1515
+ if (cached && typeof cached === 'object') {
1516
+ showPopup(x, y, cached.title || label, cached.html);
1517
+ } else if (cached === false) {
1518
+ showPopup(x, y, label,
1519
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1520
+ '<p style="margin:0;color:#888;font-family:sans-serif;font-size:0.82rem">Preview not available</p>');
1521
+ } else {
1522
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>');
1523
+ if (cached === undefined) {
1524
+ cache[path] = null;
1525
+ fetch(path)
1526
+ .then(function(r) { return r.ok ? r.json() : null; })
1527
+ .then(function(data) {
1528
+ if (!data) { cache[path] = false; updatePopup('<p style="margin:0;color:#c44;font-family:sans-serif;font-size:0.85rem">Preview unavailable.</p>'); return; }
1529
+ cache[path] = { title: data.title, html: data.html };
1530
+ updatePopup(data.html, data.title || label);
1531
+ })
1532
+ .catch(function() {
1533
+ cache[path] = false;
1534
+ updatePopup('<p style="margin:0;color:#c44;font-family:sans-serif;font-size:0.85rem">Preview unavailable.</p>');
1535
+ });
1536
+ }
1537
+ }
1538
+ } else if (isExternal(href)) {
1539
+ var extKey = 'ext:' + href;
1540
+ var extCached = cache[extKey];
1541
+ if (extCached && typeof extCached === 'object') {
1542
+ showPopup(x, y, extCached.title || label,
1543
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1544
+ '<div style="font-family:sans-serif;font-size:0.82rem;line-height:1.55;color:#444;margin-top:0.5rem">' + escHtml(extCached.text) + '</div>');
1545
+ } else if (extCached === false) {
1546
+ showPopup(x, y, label,
1547
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1548
+ '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
1549
+ } else {
1550
+ showPopup(x, y, label, '<p style="opacity:0.5;margin:0;font-family:sans-serif">Loading\u2026</p>');
1551
+ if (extCached === undefined) {
1552
+ cache[extKey] = null;
1553
+ fetch('/fetch?url=' + encodeURIComponent(href))
1554
+ .then(function(r) { return r.ok ? r.json() : null; })
1555
+ .then(function(data) {
1556
+ if (!data || data.error) {
1557
+ cache[extKey] = false;
1558
+ updatePopup(
1559
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1560
+ '<p style="margin:0.5rem 0 0;color:#888;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
1561
+ return;
1562
+ }
1563
+ cache[extKey] = { title: data.title, text: data.text };
1564
+ updatePopup(
1565
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1566
+ '<div style="font-family:sans-serif;font-size:0.82rem;line-height:1.55;color:#444;margin-top:0.5rem">' + escHtml(data.text) + '</div>',
1567
+ data.title || label);
1568
+ })
1569
+ .catch(function() {
1570
+ cache[extKey] = false;
1571
+ updatePopup(
1572
+ '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>' +
1573
+ '<p style="margin:0.5rem 0 0;color:#c44;font-family:sans-serif;font-size:0.82rem">Could not fetch page content.</p>');
1574
+ });
1575
+ }
1576
+ }
1577
+ } else {
1578
+ showPopup(x, y, label, '<div class="link-ctx-popup-url">' + escHtml(href) + '</div>');
1579
+ }
1580
+ }
1581
+
1582
+ // Right-click (desktop + most mobile browsers fire contextmenu on long-press)
1583
+ document.addEventListener('contextmenu', function(e) {
1584
+ if (popup && popup.contains(e.target)) { e.preventDefault(); return; }
1585
+ var anchor = findLink(e.target);
1586
+ if (!anchor) return;
1587
+ var href = anchor.getAttribute('href');
1588
+ if (!href || isAnchorOnly(href)) return;
1589
+ e.preventDefault();
1590
+ clearTimeout(longPressTimer);
1591
+ pendingTouch = null;
1592
+ if (!longPressHandled) handleLink(anchor, e.clientX, e.clientY);
1593
+ longPressHandled = false;
1594
+ });
1595
+
1596
+ // Long-press fallback for touch devices that don't fire contextmenu
1597
+ document.addEventListener('touchstart', function(e) {
1598
+ if (e.touches.length !== 1) return;
1599
+ if (popup && popup.contains(e.target)) return;
1600
+ var anchor = findLink(e.target);
1601
+ if (!anchor) return;
1602
+ var href = anchor.getAttribute('href');
1603
+ if (!href || isAnchorOnly(href)) return;
1604
+ touchMoved = false;
1605
+ longPressHandled = false;
1606
+ var t = e.touches[0];
1607
+ pendingTouch = { anchor: anchor, x: t.clientX, y: t.clientY };
1608
+ clearTimeout(longPressTimer);
1609
+ longPressTimer = setTimeout(function() {
1610
+ if (!touchMoved && pendingTouch) {
1611
+ longPressHandled = true;
1612
+ handleLink(pendingTouch.anchor, pendingTouch.x, pendingTouch.y);
1613
+ pendingTouch = null;
1614
+ setTimeout(function() { longPressHandled = false; }, 400);
1615
+ }
1616
+ }, 600);
1617
+ }, { passive: true });
1618
+
1619
+ document.addEventListener('touchmove', function() {
1620
+ touchMoved = true;
1621
+ clearTimeout(longPressTimer);
1622
+ pendingTouch = null;
1623
+ }, { passive: true });
1624
+
1625
+ document.addEventListener('touchend', function() {
1626
+ clearTimeout(longPressTimer);
1627
+ pendingTouch = null;
1628
+ }, { passive: true });
1629
+
1630
+ document.addEventListener('touchcancel', function() {
1631
+ clearTimeout(longPressTimer);
1632
+ pendingTouch = null;
1633
+ longPressHandled = false;
1634
+ }, { passive: true });
1635
+
1636
+ document.addEventListener('click', function(e) {
1637
+ if (popup && !popup.contains(e.target)) hidePopup();
1638
+ });
1639
+ document.addEventListener('keydown', function(e) {
1640
+ if (e.key === 'Escape' && popup) hidePopup();
1641
+ });
1642
+ })();
1643
+
1325
1644
  <% if settings.link_tooltips %>
1326
1645
  // Link preview popup for local markdown links
1327
1646
  (function() {
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.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn