ligarb 0.4.0 → 0.6.0
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/assets/review.css +682 -0
- data/assets/review.js +684 -0
- data/assets/serve.js +97 -0
- data/assets/style.css +103 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +32 -4
- data/lib/ligarb/claude_runner.rb +313 -0
- data/lib/ligarb/cli.rb +207 -9
- data/lib/ligarb/config.rb +25 -1
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +133 -0
- data/lib/ligarb/server.rb +1218 -0
- data/lib/ligarb/template.rb +7 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +96 -18
- data/templates/book.html.erb +226 -32
- metadata +36 -1
data/assets/serve.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ligarb serve — SSE-based live reload + reload button
|
|
2
|
+
(function() {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
var refreshing = false;
|
|
6
|
+
|
|
7
|
+
// Create reload button (hidden by default, shown when build changes)
|
|
8
|
+
var reloadBtn = document.createElement('button');
|
|
9
|
+
reloadBtn.id = 'ligarb-reload';
|
|
10
|
+
reloadBtn.innerHTML = '↻';
|
|
11
|
+
reloadBtn.title = 'New build available — click to reload';
|
|
12
|
+
reloadBtn.style.display = 'none';
|
|
13
|
+
reloadBtn.addEventListener('click', function() {
|
|
14
|
+
refreshContent();
|
|
15
|
+
});
|
|
16
|
+
document.body.appendChild(reloadBtn);
|
|
17
|
+
|
|
18
|
+
function showReloadButton() {
|
|
19
|
+
reloadBtn.style.display = 'flex';
|
|
20
|
+
reloadBtn.classList.add('has-update');
|
|
21
|
+
reloadBtn.classList.remove('refreshing');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hideReloadButton() {
|
|
25
|
+
reloadBtn.style.display = 'none';
|
|
26
|
+
reloadBtn.classList.remove('has-update', 'refreshing');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Refresh book content without full page reload
|
|
30
|
+
function refreshContent() {
|
|
31
|
+
if (refreshing) return;
|
|
32
|
+
refreshing = true;
|
|
33
|
+
reloadBtn.classList.remove('has-update');
|
|
34
|
+
reloadBtn.classList.add('refreshing');
|
|
35
|
+
|
|
36
|
+
fetch((window._ligarbBase || '/') + '?_t=' + Date.now())
|
|
37
|
+
.then(function(r) { return r.text(); })
|
|
38
|
+
.then(function(html) {
|
|
39
|
+
var parser = new DOMParser();
|
|
40
|
+
var doc = parser.parseFromString(html, 'text/html');
|
|
41
|
+
var newMain = doc.getElementById('content');
|
|
42
|
+
var oldMain = document.getElementById('content');
|
|
43
|
+
if (newMain && oldMain) {
|
|
44
|
+
var scrollY = window.scrollY;
|
|
45
|
+
oldMain.innerHTML = newMain.innerHTML;
|
|
46
|
+
var hash = location.hash.replace('#', '');
|
|
47
|
+
if (hash) {
|
|
48
|
+
// Show the current chapter without scrolling to top
|
|
49
|
+
var slug = hash.split('--')[0];
|
|
50
|
+
var chapters = oldMain.querySelectorAll('.chapter');
|
|
51
|
+
chapters.forEach(function(el) {
|
|
52
|
+
el.style.display = el.id === 'chapter-' + slug ? 'block' : 'none';
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
window.scrollTo(0, scrollY);
|
|
56
|
+
|
|
57
|
+
// Re-initialize syntax highlighting and special blocks
|
|
58
|
+
if (typeof hljs !== 'undefined') hljs.highlightAll();
|
|
59
|
+
if (typeof mermaid !== 'undefined') {
|
|
60
|
+
var unrendered = oldMain.querySelectorAll('.mermaid:not([data-processed])');
|
|
61
|
+
if (unrendered.length > 0) mermaid.run({nodes: unrendered});
|
|
62
|
+
}
|
|
63
|
+
if (typeof katex !== 'undefined') {
|
|
64
|
+
oldMain.querySelectorAll('.math-block[data-math]').forEach(function(el) {
|
|
65
|
+
if (el.childNodes.length === 0) {
|
|
66
|
+
try { katex.render(el.getAttribute('data-math'), el, {displayMode: true, throwOnError: false}); }
|
|
67
|
+
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
oldMain.querySelectorAll('.math-inline[data-math]').forEach(function(el) {
|
|
71
|
+
if (el.childNodes.length === 0) {
|
|
72
|
+
try { katex.render(el.getAttribute('data-math'), el, {displayMode: false, throwOnError: false}); }
|
|
73
|
+
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
refreshing = false;
|
|
79
|
+
hideReloadButton();
|
|
80
|
+
})
|
|
81
|
+
.catch(function() {
|
|
82
|
+
refreshing = false;
|
|
83
|
+
hideReloadButton();
|
|
84
|
+
location.reload();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// SSE connection
|
|
89
|
+
var eventSource = new EventSource((window._ligarbAPI || '/_ligarb') + '/events');
|
|
90
|
+
|
|
91
|
+
eventSource.addEventListener('build_updated', function() {
|
|
92
|
+
showReloadButton();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Expose for review.js
|
|
96
|
+
window._ligarbEvents = eventSource;
|
|
97
|
+
})();
|
data/assets/style.css
CHANGED
|
@@ -368,6 +368,18 @@ body {
|
|
|
368
368
|
margin-right: 0.5rem;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
.search-match-count {
|
|
372
|
+
display: inline-block;
|
|
373
|
+
background: var(--color-accent);
|
|
374
|
+
color: #fff;
|
|
375
|
+
font-size: 0.7rem;
|
|
376
|
+
padding: 0.05em 0.45em;
|
|
377
|
+
border-radius: 8px;
|
|
378
|
+
margin-left: 0.4em;
|
|
379
|
+
vertical-align: middle;
|
|
380
|
+
line-height: 1.4;
|
|
381
|
+
}
|
|
382
|
+
|
|
371
383
|
/* Search highlight */
|
|
372
384
|
mark.search-highlight {
|
|
373
385
|
background: #fef08a;
|
|
@@ -435,6 +447,7 @@ mark.search-highlight {
|
|
|
435
447
|
--color-accent-light: #1c3a5c;
|
|
436
448
|
--color-code-bg: #0f172a;
|
|
437
449
|
--color-code-border: #2d3748;
|
|
450
|
+
--color-highlight: rgba(255, 243, 205, 0.15);
|
|
438
451
|
}
|
|
439
452
|
|
|
440
453
|
[data-theme="dark"] .admonition-note {
|
|
@@ -624,6 +637,72 @@ mark.search-highlight {
|
|
|
624
637
|
}
|
|
625
638
|
}
|
|
626
639
|
|
|
640
|
+
/* === Bibliography === */
|
|
641
|
+
.bibliography-list {
|
|
642
|
+
list-style: none;
|
|
643
|
+
padding-left: 0;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.bibliography-list li {
|
|
647
|
+
margin-bottom: 0.75rem;
|
|
648
|
+
padding-left: 1.5rem;
|
|
649
|
+
text-indent: -1.5rem;
|
|
650
|
+
line-height: 1.5;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.bibliography-list li a {
|
|
654
|
+
color: var(--color-accent);
|
|
655
|
+
text-decoration: none;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.bibliography-list li a:hover {
|
|
659
|
+
text-decoration: underline;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.ligarb-highlight {
|
|
663
|
+
border-radius: 4px;
|
|
664
|
+
padding: 4px 6px;
|
|
665
|
+
animation: ligarb-highlight-fade 3s ease-out forwards;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
@keyframes ligarb-highlight-fade {
|
|
669
|
+
0% { background: var(--color-highlight, #fff3cd); }
|
|
670
|
+
100% { background: transparent; }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.cite-ref {
|
|
674
|
+
font-size: 0.8em;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.bib-label {
|
|
678
|
+
font-weight: 600;
|
|
679
|
+
color: var(--color-text-muted);
|
|
680
|
+
margin-right: 0.3em;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.cite-ref a {
|
|
684
|
+
color: var(--color-accent);
|
|
685
|
+
text-decoration: none;
|
|
686
|
+
font-weight: 600;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.cite-ref a:hover {
|
|
690
|
+
text-decoration: underline;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.cite-missing {
|
|
694
|
+
color: #d32f2f;
|
|
695
|
+
background: #ffebee;
|
|
696
|
+
padding: 0 3px;
|
|
697
|
+
border-radius: 2px;
|
|
698
|
+
cursor: help;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
[data-theme="dark"] .cite-missing {
|
|
702
|
+
color: #ef9a9a;
|
|
703
|
+
background: rgba(211, 47, 47, 0.15);
|
|
704
|
+
}
|
|
705
|
+
|
|
627
706
|
/* === Index === */
|
|
628
707
|
.index-group {
|
|
629
708
|
margin-bottom: 1.5rem;
|
|
@@ -748,3 +827,27 @@ mark.search-highlight {
|
|
|
748
827
|
}
|
|
749
828
|
}
|
|
750
829
|
}
|
|
830
|
+
|
|
831
|
+
/* === Mermaid source toggle === */
|
|
832
|
+
details.mermaid-source {
|
|
833
|
+
margin-top: 0.3em;
|
|
834
|
+
font-size: 0.8em;
|
|
835
|
+
}
|
|
836
|
+
details.mermaid-source summary {
|
|
837
|
+
cursor: pointer;
|
|
838
|
+
color: #888;
|
|
839
|
+
user-select: none;
|
|
840
|
+
}
|
|
841
|
+
details.mermaid-source pre {
|
|
842
|
+
margin-top: 0.3em;
|
|
843
|
+
padding: 0.5em;
|
|
844
|
+
background: #f5f5f5;
|
|
845
|
+
border: 1px solid #ddd;
|
|
846
|
+
border-radius: 4px;
|
|
847
|
+
overflow-x: auto;
|
|
848
|
+
white-space: pre-wrap;
|
|
849
|
+
}
|
|
850
|
+
.dark-mode details.mermaid-source pre {
|
|
851
|
+
background: #2a2a2a;
|
|
852
|
+
border-color: #444;
|
|
853
|
+
}
|
data/lib/ligarb/asset_manager.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Ligarb
|
|
|
9
9
|
class AssetManager
|
|
10
10
|
ASSETS = {
|
|
11
11
|
highlight: {
|
|
12
|
-
fence_pattern: /language-(?!mermaid|math)(\w+)/,
|
|
12
|
+
fence_pattern: /language-(?!mermaid|math|functionplot)(\w+)/,
|
|
13
13
|
files: {
|
|
14
14
|
"js/highlight.min.js" => "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js",
|
|
15
15
|
"css/highlight.css" => "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css",
|
|
@@ -28,6 +28,13 @@ module Ligarb
|
|
|
28
28
|
"css/katex.min.css" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css",
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
|
+
functionplot: {
|
|
32
|
+
fence_pattern: /class="functionplot"/,
|
|
33
|
+
files: {
|
|
34
|
+
"js/d3.min.js" => "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js",
|
|
35
|
+
"js/function-plot.min.js" => "https://cdn.jsdelivr.net/npm/function-plot@1/dist/function-plot.js",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
31
38
|
}.freeze
|
|
32
39
|
|
|
33
40
|
def initialize(output_path)
|
|
@@ -60,6 +67,8 @@ module Ligarb
|
|
|
60
67
|
|
|
61
68
|
private
|
|
62
69
|
|
|
70
|
+
MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5MB
|
|
71
|
+
|
|
63
72
|
def download(url, dest)
|
|
64
73
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
65
74
|
$stderr.print "Downloading #{File.basename(dest)}... "
|
|
@@ -68,6 +77,9 @@ module Ligarb
|
|
|
68
77
|
response = fetch_with_redirects(uri)
|
|
69
78
|
|
|
70
79
|
if response.is_a?(Net::HTTPSuccess)
|
|
80
|
+
if response.body.bytesize > MAX_ASSET_SIZE
|
|
81
|
+
abort "Error: downloaded file too large: #{File.basename(dest)} (#{response.body.bytesize} bytes, limit #{MAX_ASSET_SIZE})"
|
|
82
|
+
end
|
|
71
83
|
File.write(dest, response.body)
|
|
72
84
|
$stderr.puts "done"
|
|
73
85
|
else
|
|
@@ -77,11 +89,14 @@ module Ligarb
|
|
|
77
89
|
|
|
78
90
|
def fetch_with_redirects(uri, limit = 5)
|
|
79
91
|
raise "Too many redirects" if limit == 0
|
|
92
|
+
raise "Only HTTPS URLs are supported for asset downloads" unless uri.scheme == "https"
|
|
80
93
|
|
|
81
94
|
response = Net::HTTP.get_response(uri)
|
|
82
95
|
case response
|
|
83
96
|
when Net::HTTPRedirection
|
|
84
|
-
|
|
97
|
+
new_uri = URI(response["location"])
|
|
98
|
+
raise "Redirect to non-HTTPS URL: #{new_uri}" unless new_uri.scheme == "https"
|
|
99
|
+
fetch_with_redirects(new_uri, limit - 1)
|
|
85
100
|
else
|
|
86
101
|
response
|
|
87
102
|
end
|
data/lib/ligarb/builder.rb
CHANGED
|
@@ -30,9 +30,12 @@ module Ligarb
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
bibliography = resolve_citations!(all_chapters)
|
|
34
|
+
|
|
33
35
|
html = Template.new.render(config: @config, chapters: all_chapters,
|
|
34
36
|
structure: structure, assets: assets,
|
|
35
|
-
index_entries: index_entries
|
|
37
|
+
index_entries: index_entries,
|
|
38
|
+
bibliography: bibliography)
|
|
36
39
|
|
|
37
40
|
FileUtils.mkdir_p(@config.output_path)
|
|
38
41
|
output_file = File.join(@config.output_path, "index.html")
|
|
@@ -145,6 +148,178 @@ module Ligarb
|
|
|
145
148
|
end
|
|
146
149
|
end
|
|
147
150
|
|
|
151
|
+
def resolve_citations!(all_chapters)
|
|
152
|
+
bib_path = @config.bibliography_path
|
|
153
|
+
return [] unless bib_path
|
|
154
|
+
|
|
155
|
+
unless File.exist?(bib_path)
|
|
156
|
+
abort "Error: bibliography file not found: #{bib_path}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
bib_data = load_bibliography(bib_path)
|
|
160
|
+
|
|
161
|
+
# Validate all cite keys exist
|
|
162
|
+
cited_keys = {}
|
|
163
|
+
all_chapters.each do |ch|
|
|
164
|
+
ch.cite_entries.each do |entry|
|
|
165
|
+
unless bib_data.key?(entry.key)
|
|
166
|
+
warn "Warning: unknown bibliography key '#{entry.key}' in chapter #{File.basename(ch.instance_variable_get(:@path))}"
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
cited_keys[entry.key] = true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Post-process each chapter's HTML to insert [author, year] citations
|
|
174
|
+
all_chapters.each do |ch|
|
|
175
|
+
ch.instance_variable_set(:@html, ch.html.gsub(%r{<span id="([^"]+)" data-cite-key="([^"]+)">(.*?)</span>}m) do
|
|
176
|
+
anchor_id = $1
|
|
177
|
+
key = $2
|
|
178
|
+
display_text = $3
|
|
179
|
+
ref = bib_data[key]
|
|
180
|
+
unless ref
|
|
181
|
+
next %(<span id="#{anchor_id}">#{display_text}<sup class="cite-ref cite-missing" title="Bibliography entry '#{encode_attr(key)}' not found">[#{encode_attr(key)}?]</sup></span>)
|
|
182
|
+
end
|
|
183
|
+
cite_label = format_cite_label(ref)
|
|
184
|
+
title_text = format_bib_hover(ref)
|
|
185
|
+
%(<span id="#{anchor_id}">#{display_text}<sup class="cite-ref"><a href="#bib-#{key}" title="#{encode_attr(title_text)}" onclick="showChapterAndScroll('__bibliography__', 'bib-#{key}'); return false;">[#{encode_attr(cite_label)}]</a></sup></span>)
|
|
186
|
+
end)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Build bibliography list sorted by author then year (only cited entries)
|
|
190
|
+
cited_keys.keys.map do |key|
|
|
191
|
+
ref = bib_data[key]
|
|
192
|
+
{
|
|
193
|
+
key: key,
|
|
194
|
+
author: ref["author"],
|
|
195
|
+
title: ref["title"],
|
|
196
|
+
year: ref["year"],
|
|
197
|
+
url: ref["url"],
|
|
198
|
+
label: format_cite_label(ref),
|
|
199
|
+
formatted_html: format_bib_html(ref),
|
|
200
|
+
}
|
|
201
|
+
end.sort_by { |e| [e[:author].to_s, e[:year].to_s] }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def load_bibliography(path)
|
|
205
|
+
if path.end_with?(".bib")
|
|
206
|
+
parse_bibtex(File.read(path))
|
|
207
|
+
else
|
|
208
|
+
YAML.safe_load_file(path)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse_bibtex(source)
|
|
213
|
+
result = {}
|
|
214
|
+
# Remove comment lines
|
|
215
|
+
lines = source.each_line.reject { |l| l.match?(/\A\s*%/) }.join
|
|
216
|
+
|
|
217
|
+
# Extract @type{key, ...} blocks
|
|
218
|
+
lines.scan(/@(\w+)\s*\{\s*([^,]+)\s*,(.*?)\n\s*\}/m) do |type, key, body|
|
|
219
|
+
entry = {"_type" => type.downcase}
|
|
220
|
+
body.scan(/(\w+)\s*=\s*(?:\{((?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*)\}|"([^"]*)")/) do |field, brace_val, quote_val|
|
|
221
|
+
value = (brace_val || quote_val).strip
|
|
222
|
+
# Remove BibTeX case-protection braces (e.g. {LaTeX} -> LaTeX)
|
|
223
|
+
value = value.gsub(/\{([^{}]*)\}/, '\1')
|
|
224
|
+
entry[field.downcase] = value
|
|
225
|
+
end
|
|
226
|
+
result[key.strip] = entry
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
result
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def format_bib_html(ref)
|
|
233
|
+
type = ref["_type"] || "misc"
|
|
234
|
+
parts = []
|
|
235
|
+
|
|
236
|
+
author = ref["author"]
|
|
237
|
+
parts << encode_html(author) if author
|
|
238
|
+
|
|
239
|
+
title = ref["title"]
|
|
240
|
+
if title
|
|
241
|
+
title_html = if ref["url"]
|
|
242
|
+
%(<em><a href="#{encode_attr(ref["url"])}" target="_blank" rel="noopener">#{encode_html(title)}</a></em>)
|
|
243
|
+
elsif type == "article" || type == "inproceedings"
|
|
244
|
+
%("#{encode_html(title)}")
|
|
245
|
+
else
|
|
246
|
+
"<em>#{encode_html(title)}</em>"
|
|
247
|
+
end
|
|
248
|
+
parts << title_html
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
case type
|
|
252
|
+
when "book"
|
|
253
|
+
parts << "#{encode_html(ref["edition"])}" if ref["edition"]
|
|
254
|
+
parts << encode_html(ref["publisher"]) if ref["publisher"]
|
|
255
|
+
when "article"
|
|
256
|
+
journal_parts = []
|
|
257
|
+
journal_parts << "<em>#{encode_html(ref["journal"])}</em>" if ref["journal"]
|
|
258
|
+
vol_num = []
|
|
259
|
+
vol_num << ref["volume"] if ref["volume"]
|
|
260
|
+
vol_num << "(#{ref["number"]})" if ref["number"]
|
|
261
|
+
journal_parts << vol_num.join("") unless vol_num.empty?
|
|
262
|
+
journal_parts << "pp. #{ref["pages"]}" if ref["pages"]
|
|
263
|
+
parts << journal_parts.join(", ") unless journal_parts.empty?
|
|
264
|
+
when "inproceedings"
|
|
265
|
+
conf_parts = []
|
|
266
|
+
conf_parts << "In <em>#{encode_html(ref["booktitle"])}</em>" if ref["booktitle"]
|
|
267
|
+
conf_parts << "pp. #{ref["pages"]}" if ref["pages"]
|
|
268
|
+
parts << conf_parts.join(", ") unless conf_parts.empty?
|
|
269
|
+
else
|
|
270
|
+
parts << encode_html(ref["publisher"]) if ref["publisher"]
|
|
271
|
+
parts << "<em>#{encode_html(ref["journal"])}</em>" if ref["journal"]
|
|
272
|
+
parts << "Vol. #{ref["volume"]}" if ref["volume"]
|
|
273
|
+
parts << "pp. #{ref["pages"]}" if ref["pages"]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
parts << ref["year"].to_s if ref["year"]
|
|
277
|
+
parts << encode_html(ref["editor"]) if ref["editor"]
|
|
278
|
+
parts << encode_html(ref["note"]) if ref["note"]
|
|
279
|
+
|
|
280
|
+
# Strip trailing dots from parts to avoid double periods
|
|
281
|
+
html = parts.map { |p| p.sub(/\.\z/, "") }.join(". ") + "."
|
|
282
|
+
|
|
283
|
+
if ref["doi"] && !ref["url"]
|
|
284
|
+
doi_url = ref["doi"].start_with?("http") ? ref["doi"] : "https://doi.org/#{ref["doi"]}"
|
|
285
|
+
html += %( <a href="#{encode_attr(doi_url)}" target="_blank" rel="noopener">DOI</a>)
|
|
286
|
+
elsif ref["doi"] && ref["url"]
|
|
287
|
+
doi_url = ref["doi"].start_with?("http") ? ref["doi"] : "https://doi.org/#{ref["doi"]}"
|
|
288
|
+
html += %( <a href="#{encode_attr(doi_url)}" target="_blank" rel="noopener">DOI</a>)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
html
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def format_cite_label(ref)
|
|
295
|
+
author = ref["author"].to_s.split(/[,\s]/).first || "?"
|
|
296
|
+
year = ref["year"] ? ref["year"].to_s : "n.d."
|
|
297
|
+
"#{author}, #{year}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def format_bib_hover(ref)
|
|
301
|
+
parts = []
|
|
302
|
+
parts << ref["author"] if ref["author"]
|
|
303
|
+
parts << ref["title"] if ref["title"]
|
|
304
|
+
parts << ref["publisher"] if ref["publisher"]
|
|
305
|
+
parts << ref["journal"] if ref["journal"]
|
|
306
|
+
parts << "Vol. #{ref["volume"]}" if ref["volume"]
|
|
307
|
+
parts << "No. #{ref["number"]}" if ref["number"]
|
|
308
|
+
parts << "pp. #{ref["pages"]}" if ref["pages"]
|
|
309
|
+
parts << ref["edition"] if ref["edition"]
|
|
310
|
+
parts << ref["year"].to_s if ref["year"]
|
|
311
|
+
parts << ref["note"] if ref["note"]
|
|
312
|
+
parts.join(". ") + "."
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def encode_attr(text)
|
|
316
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def encode_html(text)
|
|
320
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
321
|
+
end
|
|
322
|
+
|
|
148
323
|
def copy_images
|
|
149
324
|
images_dir = File.join(@config.base_dir, "images")
|
|
150
325
|
return unless Dir.exist?(images_dir)
|
data/lib/ligarb/chapter.rb
CHANGED
|
@@ -7,11 +7,12 @@ module Ligarb
|
|
|
7
7
|
class Chapter
|
|
8
8
|
class CrossReferenceError < StandardError; end
|
|
9
9
|
|
|
10
|
-
attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries
|
|
10
|
+
attr_reader :title, :slug, :html, :headings, :number, :appendix_letter, :index_entries, :cite_entries
|
|
11
11
|
attr_accessor :part_title, :cover, :relative_path
|
|
12
12
|
|
|
13
13
|
Heading = Struct.new(:level, :text, :id, :display_text, keyword_init: true)
|
|
14
14
|
IndexEntry = Struct.new(:term, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
|
|
15
|
+
CiteEntry = Struct.new(:key, :display_text, :chapter_slug, :anchor_id, keyword_init: true)
|
|
15
16
|
|
|
16
17
|
def initialize(path, base_dir)
|
|
17
18
|
@path = path
|
|
@@ -63,7 +64,9 @@ module Ligarb
|
|
|
63
64
|
target_path = File.expand_path(href_path, source_dir)
|
|
64
65
|
entry = chapter_map[target_path]
|
|
65
66
|
unless entry
|
|
66
|
-
|
|
67
|
+
line_no = @source.each_line.with_index(1) { |line, i| break i if line.include?(href_path) }
|
|
68
|
+
loc = line_no ? "#{@path}:#{line_no}" : File.basename(@path)
|
|
69
|
+
raise CrossReferenceError, "cross-reference target not found: #{href_path} (from #{loc})\n link text: #{link_text.empty? ? "(auto)" : link_text}\n resolved to: #{target_path}"
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
if fragment && !fragment.empty?
|
|
@@ -106,6 +109,8 @@ module Ligarb
|
|
|
106
109
|
@html = scope_footnote_ids(@html)
|
|
107
110
|
@index_entries = []
|
|
108
111
|
@html = extract_index_markers(@html)
|
|
112
|
+
@cite_entries = []
|
|
113
|
+
@html = extract_cite_markers(@html)
|
|
109
114
|
@title = @headings.first&.text || @slug
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -161,14 +166,18 @@ module Ligarb
|
|
|
161
166
|
end
|
|
162
167
|
|
|
163
168
|
def convert_special_code_blocks(html)
|
|
164
|
-
html.gsub(%r{<pre><code class="language-(mermaid|math)">(.*?)</code></pre>}m) do
|
|
169
|
+
html.gsub(%r{<pre><code class="language-(mermaid|math|functionplot)">(.*?)</code></pre>}m) do
|
|
165
170
|
lang = $1
|
|
166
171
|
raw = decode_entities($2)
|
|
167
172
|
case lang
|
|
168
173
|
when "mermaid"
|
|
169
|
-
|
|
174
|
+
escaped = raw.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
175
|
+
%(<div class="mermaid">\n#{raw}</div>) +
|
|
176
|
+
%(<details class="mermaid-source"><summary>mermaid source</summary><pre>#{escaped}</pre></details>)
|
|
170
177
|
when "math"
|
|
171
178
|
%(<div class="math-block" data-math="#{encode_attr(raw)}"></div>)
|
|
179
|
+
when "functionplot"
|
|
180
|
+
%(<div class="functionplot" data-plot="#{encode_attr(raw)}"></div>)
|
|
172
181
|
end
|
|
173
182
|
end
|
|
174
183
|
end
|
|
@@ -258,6 +267,25 @@ module Ligarb
|
|
|
258
267
|
end
|
|
259
268
|
end
|
|
260
269
|
|
|
270
|
+
def extract_cite_markers(html)
|
|
271
|
+
cite_count = 0
|
|
272
|
+
html.gsub(%r{<a\s+href="#cite:([^"]+)">(.*?)</a>}m) do
|
|
273
|
+
key = $1
|
|
274
|
+
display_text = $2
|
|
275
|
+
anchor_id = "#{@slug}--cite-#{cite_count}"
|
|
276
|
+
cite_count += 1
|
|
277
|
+
|
|
278
|
+
@cite_entries << CiteEntry.new(
|
|
279
|
+
key: key,
|
|
280
|
+
display_text: display_text,
|
|
281
|
+
chapter_slug: @slug,
|
|
282
|
+
anchor_id: anchor_id
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
%(<span id="#{anchor_id}" data-cite-key="#{key}">#{display_text}</span>)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
261
289
|
def rewrite_image_paths(html)
|
|
262
290
|
html.gsub(/(<img\s[^>]*src=")([^"]+)(")/) do
|
|
263
291
|
prefix = $1
|