markdownr 0.4.0 → 0.4.2

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: 719c684f6ed25cb57662a6a78234695ef2fea07b1c4f301159a79e0383bd7426
4
- data.tar.gz: 1c1b1fc19db8308c8e47479a45b3fea84bb797bfe22fb16e4b5e19c8387e8c0e
3
+ metadata.gz: 7fa7bf05bfbfb6241e8e616541dbe810e9eadcae27466e18a0861b12f008221b
4
+ data.tar.gz: e0124174f4645e6a611f3be1e1be778d9a342ff14f2dc9823fa07e2b33171789
5
5
  SHA512:
6
- metadata.gz: fccc91e9f80f3848af6bb2bede88e65ebea736275189e2f7beb9db326c948d9c85c2000da77386acc71a424626a0f6b6e7115c9df1d2ff157956c56126706f58
7
- data.tar.gz: 8d6c8c6cf5dbf13040ccc83e891548e20f2fc7f30f385b3e2ee19d8adfa56273dc385cad897d3e17e88cd2ab5fa02d08299e4b446d5c3e0b8134700841e34f99
6
+ metadata.gz: 625f0a9bc04adb7bf2d2eeb954b8d0a377b96ea28ad9341eeccd4603f7142b152eb690cf66028836101312ef2f2ce56ae9f8c9135ff0f626932831e5b0041e95
7
+ data.tar.gz: 92b68d478a360d2b005ca7c8d10de909e73a263e4056f37c010f1a5d79bf97aec0e48cbccdab86eb8a08f698e73b63a36ae45585439a71256967b556d4eade0e
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: [] }
@@ -176,22 +177,28 @@ module MarkdownServer
176
177
 
177
178
  def resolve_wiki_link(name)
178
179
  filename = "#{name}.md"
180
+ base = File.realpath(root_dir)
181
+ exact_match = nil
182
+ ci_match = nil
179
183
 
180
- # Search all subdirectories
181
- Dir.glob(File.join(root_dir, "**", filename)).each do |path|
182
- real = File.realpath(path)
183
- base = File.realpath(root_dir)
184
+ Dir.glob(File.join(base, "**", filename), File::FNM_CASEFOLD).each do |path|
185
+ begin
186
+ real = File.realpath(path)
187
+ rescue Errno::ENOENT
188
+ next
189
+ end
190
+ next unless real.start_with?(base)
184
191
  relative = real.sub("#{base}/", "")
185
192
  first_segment = relative.split("/").first
186
193
  next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
187
- return relative
194
+ if File.basename(real) == filename
195
+ exact_match ||= relative
196
+ else
197
+ ci_match ||= relative
198
+ end
188
199
  end
189
200
 
190
- # Try root level
191
- path = File.join(root_dir, filename)
192
- return filename if File.exist?(path)
193
-
194
- nil
201
+ exact_match || ci_match
195
202
  end
196
203
 
197
204
  def render_inline_wiki_links(str)
@@ -430,6 +437,7 @@ module MarkdownServer
430
437
  rescue RegexpError => e
431
438
  raise RegexpError, e.message
432
439
  end
440
+
433
441
  end
434
442
 
435
443
  # Routes
@@ -472,6 +480,41 @@ module MarkdownServer
472
480
  send_file real_path, disposition: "attachment"
473
481
  end
474
482
 
483
+ get "/preview/*" do
484
+ content_type :json
485
+ halt 404, '{"error":"disabled"}' unless settings.link_tooltips
486
+
487
+ requested = params["splat"].first.to_s
488
+ base = File.realpath(root_dir)
489
+ full = File.join(base, requested)
490
+
491
+ begin
492
+ real = File.realpath(full)
493
+ rescue Errno::ENOENT
494
+ halt 404, '{"error":"not found"}'
495
+ end
496
+
497
+ halt 403, '{"error":"forbidden"}' unless real.start_with?(base)
498
+
499
+ relative = real.sub("#{base}/", "")
500
+ first_segment = relative.split("/").first
501
+ halt 403, '{"error":"forbidden"}' if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
502
+
503
+ halt 404, '{"error":"not found"}' unless File.file?(real) && File.extname(real).downcase == ".md"
504
+
505
+ begin
506
+ content = File.read(real, encoding: "utf-8")
507
+ rescue
508
+ halt 404, '{"error":"not found"}'
509
+ end
510
+
511
+ meta, body = parse_frontmatter(content)
512
+ title = (meta.is_a?(Hash) && meta["title"]) || File.basename(real, ".md")
513
+ html = render_markdown(body)
514
+
515
+ JSON.dump({ title: title.to_s, html: html })
516
+ end
517
+
475
518
  get "/search/?*" do
476
519
  requested = params["splat"].first.to_s.chomp("/")
477
520
  @query = params[:q].to_s.strip
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
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,136 @@
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('click', hidePopup);
1427
+ a.addEventListener('mouseenter', function() {
1428
+ hoverTimer = setTimeout(function() { fetchAndShow(a); }, 300);
1429
+ });
1430
+ a.addEventListener('mouseleave', function() {
1431
+ clearTimeout(hoverTimer);
1432
+ hideTimer = setTimeout(hidePopup, 10);
1433
+ });
1434
+
1435
+ var touchMoved = false;
1436
+ a.addEventListener('touchstart', function() { touchMoved = false; }, { passive: true });
1437
+ a.addEventListener('touchmove', function() { touchMoved = true; }, { passive: true });
1438
+ a.addEventListener('touchend', function(e) {
1439
+ if (touchMoved) return;
1440
+ if (activeAnchor === a && activePopup) return; // second tap → navigate
1441
+ e.preventDefault();
1442
+ document.querySelectorAll('.link-preview-popup').forEach(function(p) {
1443
+ if (p.parentNode) p.parentNode.removeChild(p);
1444
+ });
1445
+ fetchAndShow(a);
1446
+ });
1447
+ });
1448
+
1449
+ document.addEventListener('click', function(e) {
1450
+ if (activePopup && activeAnchor && !activeAnchor.contains(e.target)) hidePopup();
1451
+ });
1452
+ })();
1453
+ <% end %>
1454
+
1244
1455
  </script>
1245
1456
  </body>
1246
1457
  </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.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Dunn