ligarb 0.4.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.
@@ -12,7 +12,7 @@ module Ligarb
12
12
  @css_path = File.join(ASSETS_DIR, "style.css")
13
13
  end
14
14
 
15
- def render(config:, chapters:, structure:, assets:, index_entries: [])
15
+ def render(config:, chapters:, structure:, assets:, index_entries: [], bibliography: [])
16
16
  css = File.read(@css_path)
17
17
  template = File.read(@template_path)
18
18
 
@@ -34,6 +34,7 @@ module Ligarb
34
34
  b.local_variable_set(:ai_generated, config.ai_generated)
35
35
  b.local_variable_set(:footer, config.effective_footer)
36
36
  b.local_variable_set(:index_tree, build_index_tree(index_entries, chapters))
37
+ b.local_variable_set(:bibliography, bibliography)
37
38
 
38
39
  ERB.new(template, trim_mode: "-").result(b)
39
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/ligarb/writer.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "json"
4
5
  require "fileutils"
5
6
 
6
7
  module Ligarb
7
8
  class Writer
9
+ class WriterError < RuntimeError; end
10
+
8
11
  BRIEF_FIELDS_FOR_BOOK_YML = %w[author output_dir chapter_numbers style repository].freeze
9
12
 
10
13
  def initialize(brief_path, no_build: false)
@@ -19,8 +22,7 @@ module Ligarb
19
22
  book_yml_path = File.join(output_dir, "book.yml")
20
23
 
21
24
  if File.exist?(book_yml_path)
22
- $stderr.puts "Error: #{book_yml_path} already exists. Remove it first to regenerate."
23
- exit 1
25
+ raise WriterError, "#{book_yml_path} already exists. Remove it first to regenerate."
24
26
  end
25
27
 
26
28
  FileUtils.mkdir_p(output_dir)
@@ -28,8 +30,7 @@ module Ligarb
28
30
  run_claude(prompt)
29
31
 
30
32
  unless File.exist?(book_yml_path)
31
- $stderr.puts "Error: Claude did not generate book.yml in #{output_dir}"
32
- exit 1
33
+ raise WriterError, "Claude did not generate book.yml in #{output_dir}"
33
34
  end
34
35
 
35
36
  puts "Book files generated in #{output_dir}"
@@ -39,6 +40,8 @@ module Ligarb
39
40
  require_relative "builder"
40
41
  Builder.new(book_yml_path).build
41
42
  end
43
+
44
+ book_yml_path
42
45
  end
43
46
 
44
47
  def self.init_brief(directory = nil)
@@ -47,8 +50,7 @@ module Ligarb
47
50
  path = File.join(target, "brief.yml")
48
51
 
49
52
  if File.exist?(path)
50
- $stderr.puts "Error: #{path} already exists."
51
- exit 1
53
+ raise WriterError, "#{path} already exists."
52
54
  end
53
55
 
54
56
  FileUtils.mkdir_p(target)
@@ -95,21 +97,18 @@ module Ligarb
95
97
 
96
98
  def check_claude_installed!
97
99
  unless system("claude", "--version", out: File::NULL, err: File::NULL)
98
- $stderr.puts "Error: 'claude' command not found. Install Claude Code first."
99
- exit 1
100
+ raise WriterError, "'claude' command not found. Install Claude Code first."
100
101
  end
101
102
  end
102
103
 
103
104
  def load_brief
104
105
  unless File.exist?(@brief_path)
105
- $stderr.puts "Error: #{@brief_path} not found."
106
- exit 1
106
+ raise WriterError, "#{@brief_path} not found."
107
107
  end
108
108
 
109
109
  brief = YAML.safe_load_file(@brief_path)
110
110
  unless brief.is_a?(Hash) && brief["title"] && !brief["title"].empty?
111
- $stderr.puts "Error: 'title' is required in #{@brief_path}."
112
- exit 1
111
+ raise WriterError, "'title' is required in #{@brief_path}."
113
112
  end
114
113
 
115
114
  brief
@@ -148,6 +147,13 @@ module Ligarb
148
147
  lines << "In book.yml, set: #{settings}"
149
148
  end
150
149
 
150
+ sources = parse_brief_sources(brief)
151
+ if sources.any?
152
+ lines << ""
153
+ lines << "Reference sources (read these files for context):"
154
+ sources.each { |src| lines << "- #{src[:label]}: #{src[:path]}" }
155
+ end
156
+
151
157
  lines << ""
152
158
  lines << "Create all files in: #{abs_output_dir}"
153
159
  lines << "In book.yml, always set: ai_generated: true"
@@ -156,18 +162,65 @@ module Ligarb
156
162
  lines << "Each chapter: substantive content with multiple ## sections."
157
163
  lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
158
164
  lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
165
+ lines << ""
166
+ lines << "Create references.bib with real bibliography entries in BibTeX format."
167
+ lines << "In book.yml, set: bibliography: references.bib"
168
+ lines << "Cite references in chapter text using [text](#cite:key) syntax."
169
+ lines << "Search the web to find accurate bibliographic information (DOI, pages, volume, etc.)."
170
+ lines << "Do NOT fabricate references. Only include real, verifiable publications."
171
+ lines << "Use entry types: @article, @inproceedings, @book, @misc as appropriate."
172
+ lines << "Use UTF-8 directly for special characters (no LaTeX commands like \\c{c})."
173
+ lines << "BibTeX keys: authorsurname+year (e.g. knuth1984, cousot1977)."
159
174
 
160
175
  lines.join("\n")
161
176
  end
162
177
 
178
+ def parse_brief_sources(brief)
179
+ base_dir = File.dirname(@brief_path)
180
+ (brief["sources"] || []).map do |entry|
181
+ case entry
182
+ when String
183
+ { path: File.expand_path(entry, base_dir), label: File.basename(entry) }
184
+ when Hash
185
+ path = entry["path"] or raise WriterError, "source entry missing 'path'"
186
+ { path: File.expand_path(path, base_dir), label: entry.fetch("label", File.basename(path)) }
187
+ else
188
+ raise WriterError, "invalid source entry: #{entry.inspect}"
189
+ end
190
+ end
191
+ end
192
+
163
193
  def run_claude(prompt)
164
- tools = "Write,Bash,WebFetch,WebSearch"
165
- allowed = "Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
166
- cmd = ["claude", "-p", "--verbose", prompt, "--tools", tools, "--allowedTools", allowed]
167
- unless system(*cmd)
168
- $stderr.puts "Error: Claude process failed."
169
- exit 1
194
+ tools = "Read,Write,Bash,WebFetch,WebSearch"
195
+ allowed = "Read,Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
196
+ cmd = ["claude", "-p", prompt, "--tools", tools, "--allowedTools", allowed,
197
+ "--output-format", "stream-json", "--verbose"]
198
+ puts "Writing with Claude... (this may take a few minutes)"
199
+ unparsed_lines = []
200
+ IO.popen(cmd, err: [:child, :out]) do |io|
201
+ io.each_line do |line|
202
+ unless parse_stream_event(line)
203
+ unparsed_lines << line.rstrip
204
+ end
205
+ end
206
+ end
207
+ unless $?.success?
208
+ msg = unparsed_lines.reject(&:empty?).last(10).join("\n")
209
+ raise WriterError, "Claude process failed.#{"\n#{msg}" unless msg.empty?}"
210
+ end
211
+ end
212
+
213
+ def parse_stream_event(line)
214
+ json = JSON.parse(line) rescue (return false)
215
+ case json["type"]
216
+ when "content_block_start"
217
+ tool = json.dig("content_block", "tool_use")
218
+ if tool
219
+ name = tool["name"]
220
+ puts " [#{name}]..." if name
221
+ end
170
222
  end
223
+ true
171
224
  end
172
225
  end
173
226
  end
@@ -110,6 +110,11 @@
110
110
  </li>
111
111
  <%- end -%>
112
112
  <%- end -%>
113
+ <%- unless bibliography.empty? -%>
114
+ <li class="toc-chapter" data-chapter="__bibliography__">
115
+ <a href="#__bibliography__" class="toc-h1" onclick="showChapter('__bibliography__')"><%= language == 'ja' ? '参考文献' : 'Bibliography' %></a>
116
+ </li>
117
+ <%- end -%>
113
118
  <%- unless index_tree.empty? -%>
114
119
  <li class="toc-chapter" data-chapter="__index__">
115
120
  <a href="#__index__" class="toc-h1" onclick="showChapter('__index__')"><%= language == 'ja' ? '索引' : 'Index' %></a>
@@ -147,6 +152,16 @@
147
152
  <%- end -%>
148
153
  </section>
149
154
  <%- end -%>
155
+ <%- unless bibliography.empty? -%>
156
+ <section class="chapter bibliography-chapter" id="chapter-__bibliography__" style="display: none;">
157
+ <h1><%= language == 'ja' ? '参考文献' : 'Bibliography' %></h1>
158
+ <ul class="bibliography-list">
159
+ <%- bibliography.each do |entry| -%>
160
+ <li id="bib-<%= entry[:key] %>"><span class="bib-label">[<%= entry[:label] %>]</span> <%= entry[:formatted_html] %></li>
161
+ <%- end -%>
162
+ </ul>
163
+ </section>
164
+ <%- end -%>
150
165
  <%- unless index_tree.empty? -%>
151
166
  <section class="chapter index-chapter" id="chapter-__index__" style="display: none;">
152
167
  <h1><%= language == 'ja' ? '索引' : 'Index' %></h1>
@@ -175,10 +190,12 @@
175
190
 
176
191
  <script>
177
192
  (function() {
178
- var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %><%= ', "__index__"' unless index_tree.empty? %>];
193
+ var chapters = [<%= chapters.map { |c| "\"#{c.slug}\"" }.join(", ") %><%= ', "__bibliography__"' unless bibliography.empty? %><%= ', "__index__"' unless index_tree.empty? %>];
179
194
  var currentChapter = null;
180
195
 
181
- function showChapter(slug) {
196
+ var navigating = false; // flag to suppress pushState during popstate/initial load
197
+
198
+ function showChapter(slug, hash) {
182
199
  chapters.forEach(function(ch) {
183
200
  var el = document.getElementById('chapter-' + ch);
184
201
  if (el) el.style.display = (ch === slug) ? 'block' : 'none';
@@ -195,7 +212,10 @@
195
212
  });
196
213
 
197
214
  currentChapter = slug;
198
- history.replaceState(null, '', '#' + slug);
215
+ var newHash = '#' + (hash || slug);
216
+ if (!navigating) {
217
+ history.pushState(null, '', newHash);
218
+ }
199
219
  window.scrollTo(0, 0);
200
220
 
201
221
  // Re-apply highlight if search is active
@@ -231,36 +251,53 @@
231
251
  }
232
252
 
233
253
  function showChapterAndScroll(slug, headingId) {
234
- showChapter(slug);
235
- history.replaceState(null, '', '#' + headingId);
254
+ showChapter(slug, headingId);
236
255
  setTimeout(function() {
237
256
  var target = document.getElementById(headingId);
238
257
  if (target) target.scrollIntoView({ behavior: 'smooth' });
239
258
  }, 50);
240
259
  }
241
260
 
242
- // Handle initial hash
261
+ // Navigate to chapter/heading based on current URL hash
243
262
  function handleHash() {
263
+ navigating = true;
244
264
  var hash = location.hash.replace('#', '');
245
265
  if (!hash) {
246
266
  if (chapters.length > 0) showChapter(chapters[0]);
267
+ navigating = false;
247
268
  return;
248
269
  }
249
270
 
250
271
  // Check if hash matches a chapter slug directly
251
272
  if (chapters.indexOf(hash) !== -1) {
252
273
  showChapter(hash);
274
+ navigating = false;
275
+ return;
276
+ }
277
+
278
+ <%- unless bibliography.empty? -%>
279
+ // Check for bibliography entry (bib-KEY)
280
+ var bibMatch = hash.match(/^bib-(.+)/);
281
+ if (bibMatch) {
282
+ showChapter('__bibliography__', hash);
283
+ setTimeout(function() {
284
+ var target = document.getElementById(hash);
285
+ if (target) target.scrollIntoView({ behavior: 'smooth' });
286
+ }, 50);
287
+ navigating = false;
253
288
  return;
254
289
  }
290
+ <%- end -%>
255
291
 
256
292
  // Check for footnote links (fn: or fnref:)
257
293
  var fnMatch = hash.match(/^(?:fn|fnref):(.+?)--/);
258
294
  if (fnMatch) {
259
295
  var fnChSlug = fnMatch[1];
260
296
  if (chapters.indexOf(fnChSlug) !== -1) {
261
- if (currentChapter !== fnChSlug) showChapter(fnChSlug);
297
+ if (currentChapter !== fnChSlug) showChapter(fnChSlug, hash);
262
298
  var fnTarget = document.getElementById(hash);
263
299
  if (fnTarget) fnTarget.scrollIntoView({ behavior: 'smooth' });
300
+ navigating = false;
264
301
  return;
265
302
  }
266
303
  }
@@ -271,26 +308,98 @@
271
308
  var chSlug = parts[0];
272
309
  if (chapters.indexOf(chSlug) !== -1) {
273
310
  showChapterAndScroll(chSlug, hash);
311
+ navigating = false;
274
312
  return;
275
313
  }
276
314
  }
277
315
 
278
316
  // Fallback
279
317
  if (chapters.length > 0) showChapter(chapters[0]);
318
+ navigating = false;
280
319
  }
281
320
 
282
321
  // TOC search + content highlight
283
322
  var searchInput = document.getElementById('toc-search');
284
323
  var clearBtn = document.getElementById('search-clear');
285
324
 
325
+ // Build text cache for full-text search (lazy, built on first search)
326
+ var chapterTextCache = null;
327
+ function buildTextCache() {
328
+ if (chapterTextCache) return;
329
+ chapterTextCache = {};
330
+ chapters.forEach(function(slug) {
331
+ var section = document.getElementById('chapter-' + slug);
332
+ if (section) {
333
+ chapterTextCache[slug] = section.textContent.toLowerCase();
334
+ }
335
+ });
336
+ }
337
+
338
+ function countMatches(text, query) {
339
+ var count = 0;
340
+ var idx = text.indexOf(query);
341
+ while (idx !== -1) {
342
+ count++;
343
+ idx = text.indexOf(query, idx + query.length);
344
+ }
345
+ return count;
346
+ }
347
+
286
348
  function updateSearch() {
287
349
  var query = searchInput.value.toLowerCase();
350
+ clearBtn.style.display = query ? 'block' : 'none';
351
+
352
+ // Remove existing match count badges
353
+ document.querySelectorAll('.search-match-count').forEach(function(el) { el.remove(); });
354
+
288
355
  var items = document.querySelectorAll('.toc-chapter');
356
+ var partItems = document.querySelectorAll('.toc-part, .toc-appendix');
357
+
358
+ if (!query || query.length < 2) {
359
+ // Show all items when query is too short
360
+ items.forEach(function(item) { item.style.display = ''; });
361
+ partItems.forEach(function(item) { item.style.display = ''; });
362
+ highlightContent('');
363
+ return;
364
+ }
365
+
366
+ buildTextCache();
367
+
368
+ // Track which chapters match (content or TOC text)
369
+ var matchingSlugs = {};
289
370
  items.forEach(function(item) {
290
- var text = item.textContent.toLowerCase();
291
- item.style.display = text.indexOf(query) !== -1 ? '' : 'none';
371
+ var slug = item.dataset.chapter;
372
+ var tocText = item.textContent.toLowerCase();
373
+ var contentText = chapterTextCache[slug] || '';
374
+ var tocMatch = tocText.indexOf(query) !== -1;
375
+ var contentMatch = contentText.indexOf(query) !== -1;
376
+
377
+ if (tocMatch || contentMatch) {
378
+ item.style.display = '';
379
+ matchingSlugs[slug] = true;
380
+ // Show match count badge for content matches
381
+ if (contentMatch) {
382
+ var count = countMatches(contentText, query);
383
+ var badge = document.createElement('span');
384
+ badge.className = 'search-match-count';
385
+ badge.textContent = count;
386
+ var link = item.querySelector('a');
387
+ if (link) link.appendChild(badge);
388
+ }
389
+ } else {
390
+ item.style.display = 'none';
391
+ }
292
392
  });
293
- clearBtn.style.display = query ? 'block' : 'none';
393
+
394
+ // Show/hide part and appendix headers based on whether they have visible children
395
+ partItems.forEach(function(item) {
396
+ var children = item.querySelectorAll('.toc-chapter');
397
+ var hasVisible = Array.prototype.some.call(children, function(ch) {
398
+ return ch.style.display !== 'none';
399
+ });
400
+ item.style.display = hasVisible ? '' : 'none';
401
+ });
402
+
294
403
  highlightContent(searchInput.value);
295
404
  }
296
405
 
@@ -410,9 +519,12 @@
410
519
  window.showChapterAndScroll = showChapterAndScroll;
411
520
  window.renderSpecialBlocks = function() { if (currentChapter) renderSpecialBlocks(currentChapter); };
412
521
 
413
- // Initialize
522
+ // Initialize (replace initial entry so first back goes to previous page, not same page)
523
+ navigating = true;
414
524
  handleHash();
415
- window.addEventListener('hashchange', handleHash);
525
+ history.replaceState(null, '', location.hash || '#' + (chapters[0] || ''));
526
+ navigating = false;
527
+ window.addEventListener('popstate', handleHash);
416
528
  })();
417
529
  </script>
418
530
  <%- if assets.need?(:highlight) -%>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ligarb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ligarb contributors
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webrick
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: fiddle
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '1.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '1.1'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: rake
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -72,14 +100,21 @@ executables:
72
100
  extensions: []
73
101
  extra_rdoc_files: []
74
102
  files:
103
+ - assets/review.css
104
+ - assets/review.js
105
+ - assets/serve.js
75
106
  - assets/style.css
76
107
  - exe/ligarb
77
108
  - lib/ligarb/asset_manager.rb
78
109
  - lib/ligarb/builder.rb
79
110
  - lib/ligarb/chapter.rb
111
+ - lib/ligarb/claude_runner.rb
80
112
  - lib/ligarb/cli.rb
81
113
  - lib/ligarb/config.rb
82
114
  - lib/ligarb/initializer.rb
115
+ - lib/ligarb/inotify.rb
116
+ - lib/ligarb/review_store.rb
117
+ - lib/ligarb/server.rb
83
118
  - lib/ligarb/template.rb
84
119
  - lib/ligarb/version.rb
85
120
  - lib/ligarb/writer.rb