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 +4 -4
- data/bin/markdownr +5 -0
- data/lib/markdown_server/app.rb +53 -10
- data/lib/markdown_server/version.rb +1 -1
- data/views/layout.erb +211 -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: 7fa7bf05bfbfb6241e8e616541dbe810e9eadcae27466e18a0861b12f008221b
|
|
4
|
+
data.tar.gz: e0124174f4645e6a611f3be1e1be778d9a342ff14f2dc9823fa07e2b33171789
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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: [] }
|
|
@@ -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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
194
|
+
if File.basename(real) == filename
|
|
195
|
+
exact_match ||= relative
|
|
196
|
+
else
|
|
197
|
+
ci_match ||= relative
|
|
198
|
+
end
|
|
188
199
|
end
|
|
189
200
|
|
|
190
|
-
|
|
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
|
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, '&').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('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>
|