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.
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,
@@ -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",
@@ -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?
@@ -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("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">").gsub("&quot;", '"').gsub("&#39;", "'")
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