markdownr 0.4.0 → 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 +4 -4
- data/bin/markdownr +5 -0
- data/lib/markdown_server/app.rb +37 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/layout.erb +210 -0
- 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: 6d6f02606d9b0949b45cb8fb62106b61ca22f7f9c10f3cf571c560d65bcca3d1
|
|
4
|
+
data.tar.gz: 0c0a37dddc07a3f079600fc4334e89e8cdd445335ef0ddaef6b953a12b5b3f75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/markdown_server/app.rb
CHANGED
|
@@ -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
|
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, '&').replace(/</g, '<')
|
|
1339
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
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>
|