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.
@@ -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,12 +34,18 @@ 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
40
41
 
41
42
  private
42
43
 
44
+ # HTML-escape helper for ERB templates (available via binding)
45
+ def h(s)
46
+ ERB::Util.html_escape(s.to_s)
47
+ end
48
+
43
49
  # Build a sorted tree structure for the index.
44
50
  # Returns: { "A" => [ { term: "Algorithm", refs: [...] },
45
51
  # { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ligarb
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.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,10 @@ module Ligarb
39
40
  require_relative "builder"
40
41
  Builder.new(book_yml_path).build
41
42
  end
43
+
44
+ git_commit_initial(output_dir, brief["title"]) if git_available?(output_dir)
45
+
46
+ book_yml_path
42
47
  end
43
48
 
44
49
  def self.init_brief(directory = nil)
@@ -47,8 +52,7 @@ module Ligarb
47
52
  path = File.join(target, "brief.yml")
48
53
 
49
54
  if File.exist?(path)
50
- $stderr.puts "Error: #{path} already exists."
51
- exit 1
55
+ raise WriterError, "#{path} already exists."
52
56
  end
53
57
 
54
58
  FileUtils.mkdir_p(target)
@@ -88,28 +92,48 @@ module Ligarb
88
92
  puts "Created #{path}"
89
93
  puts "Created #{claude_md}" if created_claude_md
90
94
  brief_arg = directory ? " #{path}" : ""
95
+ unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target)
96
+ gitignore = File.join(target, ".gitignore")
97
+ unless File.exist?(gitignore)
98
+ File.write(gitignore, "# Generated by ligarb - remove lines as needed\nbuild/\n.ligarb/\n")
99
+ end
100
+ system("git", "init", chdir: target)
101
+ puts "Initialized git repository"
102
+ end
103
+
91
104
  puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
92
105
  end
93
106
 
94
107
  private
95
108
 
109
+ def git_available?(dir)
110
+ system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: dir)
111
+ end
112
+
113
+ def git_commit_initial(dir, title)
114
+ system("git", "add", "-A", chdir: dir, out: File::NULL, err: File::NULL)
115
+ system("git", "commit", "-m", "[ligarb] Generate book: #{title}",
116
+ chdir: dir, out: File::NULL, err: File::NULL)
117
+ end
118
+
96
119
  def check_claude_installed!
120
+ claude_path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, "claude")) }
121
+ unless claude_path
122
+ raise WriterError, "'claude' command not found. Install Claude Code first."
123
+ end
97
124
  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
125
+ raise WriterError, "'claude' command was found but failed to run. Check your Claude Code installation."
100
126
  end
101
127
  end
102
128
 
103
129
  def load_brief
104
130
  unless File.exist?(@brief_path)
105
- $stderr.puts "Error: #{@brief_path} not found."
106
- exit 1
131
+ raise WriterError, "#{@brief_path} not found."
107
132
  end
108
133
 
109
134
  brief = YAML.safe_load_file(@brief_path)
110
135
  unless brief.is_a?(Hash) && brief["title"] && !brief["title"].empty?
111
- $stderr.puts "Error: 'title' is required in #{@brief_path}."
112
- exit 1
136
+ raise WriterError, "'title' is required in #{@brief_path}."
113
137
  end
114
138
 
115
139
  brief
@@ -148,6 +172,13 @@ module Ligarb
148
172
  lines << "In book.yml, set: #{settings}"
149
173
  end
150
174
 
175
+ sources = parse_brief_sources(brief)
176
+ if sources.any?
177
+ lines << ""
178
+ lines << "Reference sources (read these files for context):"
179
+ sources.each { |src| lines << "- #{src[:label]}: #{src[:path]}" }
180
+ end
181
+
151
182
  lines << ""
152
183
  lines << "Create all files in: #{abs_output_dir}"
153
184
  lines << "In book.yml, always set: ai_generated: true"
@@ -156,18 +187,65 @@ module Ligarb
156
187
  lines << "Each chapter: substantive content with multiple ## sections."
157
188
  lines << "Use code blocks, admonitions, mermaid diagrams where appropriate."
158
189
  lines << "Chapter filenames: 01-topic.md, 02-topic.md, etc."
190
+ lines << ""
191
+ lines << "Create references.bib with real bibliography entries in BibTeX format."
192
+ lines << "In book.yml, set: bibliography: references.bib"
193
+ lines << "Cite references in chapter text using [text](#cite:key) syntax."
194
+ lines << "Search the web to find accurate bibliographic information (DOI, pages, volume, etc.)."
195
+ lines << "Do NOT fabricate references. Only include real, verifiable publications."
196
+ lines << "Use entry types: @article, @inproceedings, @book, @misc as appropriate."
197
+ lines << "Use UTF-8 directly for special characters (no LaTeX commands like \\c{c})."
198
+ lines << "BibTeX keys: authorsurname+year (e.g. knuth1984, cousot1977)."
159
199
 
160
200
  lines.join("\n")
161
201
  end
162
202
 
203
+ def parse_brief_sources(brief)
204
+ base_dir = File.dirname(@brief_path)
205
+ (brief["sources"] || []).map do |entry|
206
+ case entry
207
+ when String
208
+ { path: File.expand_path(entry, base_dir), label: File.basename(entry) }
209
+ when Hash
210
+ path = entry["path"] or raise WriterError, "source entry missing 'path'"
211
+ { path: File.expand_path(path, base_dir), label: entry.fetch("label", File.basename(path)) }
212
+ else
213
+ raise WriterError, "invalid source entry: #{entry.inspect}"
214
+ end
215
+ end
216
+ end
217
+
163
218
  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
219
+ tools = "Read,Write,Bash,WebFetch,WebSearch"
220
+ allowed = "Read,Write,Bash(mkdir:*),Bash(ls:*),Bash(ligarb:*),WebFetch,WebSearch"
221
+ cmd = ["claude", "-p", prompt, "--tools", tools, "--allowedTools", allowed,
222
+ "--output-format", "stream-json", "--verbose"]
223
+ puts "Writing with Claude... (this may take a few minutes)"
224
+ unparsed_lines = []
225
+ IO.popen(cmd, err: [:child, :out]) do |io|
226
+ io.each_line do |line|
227
+ unless parse_stream_event(line)
228
+ unparsed_lines << line.rstrip
229
+ end
230
+ end
231
+ end
232
+ unless $?.success?
233
+ msg = unparsed_lines.reject(&:empty?).last(10).join("\n")
234
+ raise WriterError, "Claude process failed.#{"\n#{msg}" unless msg.empty?}"
235
+ end
236
+ end
237
+
238
+ def parse_stream_event(line)
239
+ json = JSON.parse(line) rescue (return false)
240
+ case json["type"]
241
+ when "content_block_start"
242
+ tool = json.dig("content_block", "tool_use")
243
+ if tool
244
+ name = tool["name"]
245
+ puts " [#{name}]..." if name
246
+ end
170
247
  end
248
+ true
171
249
  end
172
250
  end
173
251
  end