ligarb 0.3.0 → 0.5.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 +665 -0
- data/assets/review.js +681 -0
- data/assets/serve.js +76 -0
- data/assets/style.css +95 -0
- data/lib/ligarb/asset_manager.rb +1 -1
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +45 -2
- data/lib/ligarb/claude_runner.rb +185 -0
- data/lib/ligarb/cli.rb +169 -3
- data/lib/ligarb/config.rb +39 -1
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +112 -0
- data/lib/ligarb/server.rb +1091 -0
- data/lib/ligarb/template.rb +4 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +226 -0
- data/templates/book.html.erb +141 -13
- metadata +37 -1
data/assets/serve.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
refreshing = false;
|
|
58
|
+
hideReloadButton();
|
|
59
|
+
})
|
|
60
|
+
.catch(function() {
|
|
61
|
+
refreshing = false;
|
|
62
|
+
hideReloadButton();
|
|
63
|
+
location.reload();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// SSE connection
|
|
68
|
+
var eventSource = new EventSource((window._ligarbAPI || '/_ligarb') + '/events');
|
|
69
|
+
|
|
70
|
+
eventSource.addEventListener('build_updated', function() {
|
|
71
|
+
showReloadButton();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Expose for review.js
|
|
75
|
+
window._ligarbEvents = eventSource;
|
|
76
|
+
})();
|
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;
|
|
@@ -624,6 +636,61 @@ mark.search-highlight {
|
|
|
624
636
|
}
|
|
625
637
|
}
|
|
626
638
|
|
|
639
|
+
/* === Bibliography === */
|
|
640
|
+
.bibliography-list {
|
|
641
|
+
list-style: none;
|
|
642
|
+
padding-left: 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.bibliography-list li {
|
|
646
|
+
margin-bottom: 0.75rem;
|
|
647
|
+
padding-left: 1.5rem;
|
|
648
|
+
text-indent: -1.5rem;
|
|
649
|
+
line-height: 1.5;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.bibliography-list li a {
|
|
653
|
+
color: var(--color-accent);
|
|
654
|
+
text-decoration: none;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.bibliography-list li a:hover {
|
|
658
|
+
text-decoration: underline;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.cite-ref {
|
|
662
|
+
font-size: 0.8em;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.bib-label {
|
|
666
|
+
font-weight: 600;
|
|
667
|
+
color: var(--color-text-muted);
|
|
668
|
+
margin-right: 0.3em;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.cite-ref a {
|
|
672
|
+
color: var(--color-accent);
|
|
673
|
+
text-decoration: none;
|
|
674
|
+
font-weight: 600;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.cite-ref a:hover {
|
|
678
|
+
text-decoration: underline;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.cite-missing {
|
|
682
|
+
color: #d32f2f;
|
|
683
|
+
background: #ffebee;
|
|
684
|
+
padding: 0 3px;
|
|
685
|
+
border-radius: 2px;
|
|
686
|
+
cursor: help;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
[data-theme="dark"] .cite-missing {
|
|
690
|
+
color: #ef9a9a;
|
|
691
|
+
background: rgba(211, 47, 47, 0.15);
|
|
692
|
+
}
|
|
693
|
+
|
|
627
694
|
/* === Index === */
|
|
628
695
|
.index-group {
|
|
629
696
|
margin-bottom: 1.5rem;
|
|
@@ -670,6 +737,34 @@ mark.search-highlight {
|
|
|
670
737
|
text-decoration: underline;
|
|
671
738
|
}
|
|
672
739
|
|
|
740
|
+
/* === AI Generated Notice === */
|
|
741
|
+
.ai-badge {
|
|
742
|
+
display: inline-block;
|
|
743
|
+
font-size: 0.7rem;
|
|
744
|
+
font-weight: 600;
|
|
745
|
+
color: #ca8a04;
|
|
746
|
+
background: #fefce8;
|
|
747
|
+
border: 1px solid #facc15;
|
|
748
|
+
border-radius: 3px;
|
|
749
|
+
padding: 0.1rem 0.4rem;
|
|
750
|
+
margin-top: 0.3rem;
|
|
751
|
+
letter-spacing: 0.03em;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
[data-theme="dark"] .ai-badge {
|
|
755
|
+
color: #fbbf24;
|
|
756
|
+
background: #2e2a1a;
|
|
757
|
+
border-color: #854d0e;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.chapter-footer {
|
|
761
|
+
margin-top: 2rem;
|
|
762
|
+
padding: 0.5rem 0.75rem;
|
|
763
|
+
font-size: 0.8rem;
|
|
764
|
+
color: var(--color-text-muted);
|
|
765
|
+
border-top: 1px solid var(--color-border);
|
|
766
|
+
}
|
|
767
|
+
|
|
673
768
|
/* === Print === */
|
|
674
769
|
@media print {
|
|
675
770
|
.sidebar,
|
data/lib/ligarb/asset_manager.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Ligarb
|
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
katex: {
|
|
25
|
-
fence_pattern: /class="math-block"/,
|
|
25
|
+
fence_pattern: /class="math-(block|inline)"/,
|
|
26
26
|
files: {
|
|
27
27
|
"js/katex.min.js" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.js",
|
|
28
28
|
"css/katex.min.css" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css",
|
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?
|
|
@@ -101,10 +104,13 @@ module Ligarb
|
|
|
101
104
|
@html = rewrite_image_paths(doc.to_html)
|
|
102
105
|
@html = apply_heading_ids(@html)
|
|
103
106
|
@html = convert_special_code_blocks(@html)
|
|
107
|
+
@html = convert_inline_math(@html)
|
|
104
108
|
@html = convert_admonitions(@html)
|
|
105
109
|
@html = scope_footnote_ids(@html)
|
|
106
110
|
@index_entries = []
|
|
107
111
|
@html = extract_index_markers(@html)
|
|
112
|
+
@cite_entries = []
|
|
113
|
+
@html = extract_cite_markers(@html)
|
|
108
114
|
@title = @headings.first&.text || @slug
|
|
109
115
|
end
|
|
110
116
|
|
|
@@ -172,6 +178,24 @@ module Ligarb
|
|
|
172
178
|
end
|
|
173
179
|
end
|
|
174
180
|
|
|
181
|
+
def convert_inline_math(html)
|
|
182
|
+
# Protect <pre>...</pre> and <code>...</code> from conversion
|
|
183
|
+
placeholders = []
|
|
184
|
+
protected = html.gsub(%r{<(pre|code)([ >])(.*?)</\1>}m) do
|
|
185
|
+
placeholders << $&
|
|
186
|
+
"\x00PROTECT#{placeholders.size - 1}\x00"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Convert $...$ to inline math (exclude $$, and $ followed/preceded by space)
|
|
190
|
+
result = protected.gsub(/(?<!\$)\$(?!\$)(?!\s)(.+?)(?<!\s)(?<!\$)\$(?!\$)/m) do
|
|
191
|
+
raw = decode_entities($1)
|
|
192
|
+
%(<span class="math-inline" data-math="#{encode_attr(raw)}"></span>)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Restore protected parts
|
|
196
|
+
result.gsub(/\x00PROTECT(\d+)\x00/) { placeholders[$1.to_i] }
|
|
197
|
+
end
|
|
198
|
+
|
|
175
199
|
def decode_entities(text)
|
|
176
200
|
text.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub(""", '"').gsub("'", "'")
|
|
177
201
|
end
|
|
@@ -239,6 +263,25 @@ module Ligarb
|
|
|
239
263
|
end
|
|
240
264
|
end
|
|
241
265
|
|
|
266
|
+
def extract_cite_markers(html)
|
|
267
|
+
cite_count = 0
|
|
268
|
+
html.gsub(%r{<a\s+href="#cite:([^"]+)">(.*?)</a>}m) do
|
|
269
|
+
key = $1
|
|
270
|
+
display_text = $2
|
|
271
|
+
anchor_id = "#{@slug}--cite-#{cite_count}"
|
|
272
|
+
cite_count += 1
|
|
273
|
+
|
|
274
|
+
@cite_entries << CiteEntry.new(
|
|
275
|
+
key: key,
|
|
276
|
+
display_text: display_text,
|
|
277
|
+
chapter_slug: @slug,
|
|
278
|
+
anchor_id: anchor_id
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
%(<span id="#{anchor_id}" data-cite-key="#{key}">#{display_text}</span>)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
242
285
|
def rewrite_image_paths(html)
|
|
243
286
|
html.gsub(/(<img\s[^>]*src=")([^"]+)(")/) do
|
|
244
287
|
prefix = $1
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require_relative "cli"
|
|
6
|
+
|
|
7
|
+
module Ligarb
|
|
8
|
+
class ClaudeRunner
|
|
9
|
+
PATCH_RE = %r{<patch(?:\s+file="([^"]*)")?>\s*<<<\n(.*?)\n===\n(.*?)\n>>>\s*</patch>}m
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def installed?
|
|
16
|
+
system("claude", "--version", out: File::NULL, err: File::NULL)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Run claude -p with the given prompt. Returns the text response.
|
|
20
|
+
def run(prompt)
|
|
21
|
+
cmd = ["claude", "-p", "-", "--model", "opus", "--output-format", "json"]
|
|
22
|
+
stdout, stderr, status = Open3.capture3(*cmd, stdin_data: prompt)
|
|
23
|
+
|
|
24
|
+
unless status.success?
|
|
25
|
+
return { "error" => "Claude process failed: #{stderr.strip}" }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
result = JSON.parse(stdout)
|
|
30
|
+
text = result["result"] || stdout
|
|
31
|
+
{ "text" => text }
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
{ "text" => stdout.strip }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build prompt for reviewing a comment on selected text.
|
|
38
|
+
# Asks Claude to include <patch> blocks with concrete replacements.
|
|
39
|
+
# Points Claude to book.yml so it can read chapters and bibliography as needed.
|
|
40
|
+
def review_prompt(review)
|
|
41
|
+
ctx = review["context"]
|
|
42
|
+
source_file = ctx["source_file"]
|
|
43
|
+
config_path = File.join(@config.base_dir, "book.yml")
|
|
44
|
+
|
|
45
|
+
messages = review["messages"].map { |m| "#{m["role"]}: #{m["content"]}" }.join("\n\n")
|
|
46
|
+
|
|
47
|
+
uploaded_section = uploaded_files_prompt_section(ctx)
|
|
48
|
+
|
|
49
|
+
<<~PROMPT
|
|
50
|
+
You are reviewing a book built with ligarb.
|
|
51
|
+
|
|
52
|
+
<ligarb-spec>
|
|
53
|
+
#{CLI.spec_text}
|
|
54
|
+
</ligarb-spec>
|
|
55
|
+
#{uploaded_section}
|
|
56
|
+
Book configuration: #{config_path}
|
|
57
|
+
Read this file first to understand the book structure (chapters, bibliography, sources, etc.).
|
|
58
|
+
Then read the chapter files and other files as needed to respond to the comment.
|
|
59
|
+
|
|
60
|
+
The comment was made on: #{source_file}
|
|
61
|
+
The reader selected this text: "#{ctx["selected_text"]}"
|
|
62
|
+
Under heading: #{ctx["heading_id"]}
|
|
63
|
+
|
|
64
|
+
Conversation so far:
|
|
65
|
+
#{messages}
|
|
66
|
+
|
|
67
|
+
Respond to the reader's comment with a concise explanation, then provide
|
|
68
|
+
concrete patches. Each patch must use this exact format:
|
|
69
|
+
|
|
70
|
+
<patch file="relative/path/to/file.md">
|
|
71
|
+
<<<
|
|
72
|
+
exact text to find in the source (copied verbatim)
|
|
73
|
+
===
|
|
74
|
+
replacement text
|
|
75
|
+
>>>
|
|
76
|
+
</patch>
|
|
77
|
+
|
|
78
|
+
Rules:
|
|
79
|
+
- The file attribute must be the path relative to the directory containing book.yml
|
|
80
|
+
- The text between <<< and === must match the source file EXACTLY (whitespace included)
|
|
81
|
+
- You may include multiple <patch> blocks for one or more files
|
|
82
|
+
- If the comment applies to multiple chapters, read all relevant chapters and provide patches for each
|
|
83
|
+
- When adding citations ([@key]), also add the corresponding entry to the bibliography file
|
|
84
|
+
- Use ligarb Markdown features (admonitions, cross-references, index, etc.) where appropriate
|
|
85
|
+
- If no code change is needed (e.g. answering a question), omit the <patch> blocks
|
|
86
|
+
PROMPT
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extract patches from the last assistant message and apply them.
|
|
90
|
+
# Supports cross-chapter patches via the file attribute.
|
|
91
|
+
def apply_patches(review)
|
|
92
|
+
patches = extract_patches(review)
|
|
93
|
+
return { "error" => "No patches found in the conversation" } if patches.empty?
|
|
94
|
+
|
|
95
|
+
default_source = review.dig("context", "source_file")
|
|
96
|
+
|
|
97
|
+
# Group patches by target file
|
|
98
|
+
file_patches = {}
|
|
99
|
+
patches.each do |rel_path, old_text, new_text|
|
|
100
|
+
target = if rel_path && !rel_path.empty?
|
|
101
|
+
resolve_patch_file(rel_path)
|
|
102
|
+
else
|
|
103
|
+
default_source
|
|
104
|
+
end
|
|
105
|
+
next unless target
|
|
106
|
+
|
|
107
|
+
file_patches[target] ||= []
|
|
108
|
+
file_patches[target] << [old_text, new_text]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return { "error" => "No valid target files found for patches" } if file_patches.empty?
|
|
112
|
+
|
|
113
|
+
applied = 0
|
|
114
|
+
total = patches.size
|
|
115
|
+
|
|
116
|
+
file_patches.each do |file, file_patch_list|
|
|
117
|
+
unless File.exist?(file)
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
content = File.read(file)
|
|
122
|
+
changed = false
|
|
123
|
+
|
|
124
|
+
file_patch_list.each do |old_text, new_text|
|
|
125
|
+
if content.include?(old_text)
|
|
126
|
+
content = content.sub(old_text, new_text)
|
|
127
|
+
applied += 1
|
|
128
|
+
changed = true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
File.write(file, content) if changed
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return { "error" => "No patches matched the source files (0/#{total})" } if applied == 0
|
|
136
|
+
|
|
137
|
+
# Rebuild
|
|
138
|
+
config_path = File.join(@config.base_dir, "book.yml")
|
|
139
|
+
require_relative "builder"
|
|
140
|
+
begin
|
|
141
|
+
Builder.new(config_path).build
|
|
142
|
+
rescue SystemExit => e
|
|
143
|
+
return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed: #{e.message}" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{ "text" => "Applied #{applied}/#{total} patch(es) and rebuilt." }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def uploaded_files_prompt_section(ctx)
|
|
150
|
+
files = ctx["uploaded_files"]
|
|
151
|
+
return "" unless files.is_a?(Array) && !files.empty?
|
|
152
|
+
|
|
153
|
+
lines = ["\nUploaded reference files (read these for context):"]
|
|
154
|
+
files.each do |f|
|
|
155
|
+
lines << "- #{f["label"]}: #{f["path"]}"
|
|
156
|
+
end
|
|
157
|
+
lines << ""
|
|
158
|
+
lines.join("\n")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Parse <patch> blocks from assistant messages.
|
|
162
|
+
# Returns array of [file_path_or_nil, old_text, new_text].
|
|
163
|
+
def extract_patches(review)
|
|
164
|
+
(review["messages"] || [])
|
|
165
|
+
.select { |m| m["role"] == "assistant" }
|
|
166
|
+
.reverse
|
|
167
|
+
.each do |msg|
|
|
168
|
+
patches = msg["content"].scan(PATCH_RE)
|
|
169
|
+
return patches unless patches.empty?
|
|
170
|
+
end
|
|
171
|
+
[]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# Resolve a relative path from a patch to an absolute path.
|
|
177
|
+
def resolve_patch_file(rel_path)
|
|
178
|
+
absolute = File.join(@config.base_dir, rel_path)
|
|
179
|
+
return absolute if File.exist?(absolute)
|
|
180
|
+
|
|
181
|
+
# Try matching by basename against all chapter paths
|
|
182
|
+
@config.all_file_paths.find { |p| p.end_with?("/#{rel_path}") || p == rel_path }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|