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.
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
+ }
@@ -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
- fetch_with_redirects(URI(response["location"]), limit - 1)
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
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
317
+ end
318
+
319
+ def encode_html(text)
320
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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)
@@ -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
- raise CrossReferenceError, "cross-reference target not found: #{href_path} (from #{File.basename(@path)})"
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
- %(<div class="mermaid">\n#{raw}</div>)
174
+ escaped = raw.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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