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.
@@ -0,0 +1,313 @@
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*<<<[ \t]*\r?\n(.*?)\r?\n===[ \t]*\r?\n(.*?)\r?\n>>>[ \t]*\s*</patch>}m
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def installed?
16
+ claude_path = which("claude")
17
+ return :not_found unless claude_path
18
+
19
+ if system(claude_path, "--version", out: File::NULL, err: File::NULL)
20
+ true
21
+ else
22
+ :version_failed
23
+ end
24
+ end
25
+
26
+ # Run claude -p with the given prompt. Returns the text response.
27
+ def run(prompt)
28
+ cmd = ["claude", "-p", "-", "--model", "opus", "--output-format", "json"]
29
+ stdout, stderr, status = Open3.capture3(*cmd, stdin_data: prompt)
30
+
31
+ unless status.success?
32
+ return { "error" => "Claude process failed: #{stderr.strip}" }
33
+ end
34
+
35
+ begin
36
+ result = JSON.parse(stdout)
37
+ text = result["result"] || stdout
38
+ { "text" => text }
39
+ rescue JSON::ParserError
40
+ { "text" => stdout.strip }
41
+ end
42
+ end
43
+
44
+ # Build prompt for reviewing a comment on selected text.
45
+ # Asks Claude to include <patch> blocks with concrete replacements.
46
+ # Points Claude to book.yml so it can read chapters and bibliography as needed.
47
+ def review_prompt(review)
48
+ ctx = review["context"]
49
+ source_file = ctx["source_file"]
50
+ config_path = File.join(@config.base_dir, "book.yml")
51
+
52
+ messages = review["messages"].map { |m| "#{m["role"]}: #{m["content"]}" }.join("\n\n")
53
+
54
+ uploaded_section = uploaded_files_prompt_section(ctx)
55
+
56
+ <<~PROMPT
57
+ You are reviewing a book built with ligarb.
58
+
59
+ <ligarb-spec>
60
+ #{CLI.spec_text}
61
+ </ligarb-spec>
62
+ #{uploaded_section}
63
+ Book configuration: #{config_path}
64
+ Read this file first to understand the book structure (chapters, bibliography, sources, etc.).
65
+ Then read the chapter files and other files as needed to respond to the comment.
66
+
67
+ The comment was made on: #{source_file}
68
+ The reader selected this text: "#{ctx["selected_text"]}"
69
+ Under heading: #{ctx["heading_id"]}
70
+
71
+ Conversation so far:
72
+ #{messages}
73
+
74
+ Respond to the reader's comment with a concise explanation, then provide
75
+ concrete patches. Each patch must use this exact format:
76
+
77
+ <patch file="relative/path/to/file.md">
78
+ <<<
79
+ exact text to find in the source (copied verbatim)
80
+ ===
81
+ replacement text
82
+ >>>
83
+ </patch>
84
+
85
+ Rules:
86
+ - The file attribute must be the path relative to the directory containing book.yml
87
+ - The text between <<< and === must match the source file EXACTLY (whitespace included)
88
+ - You may include multiple <patch> blocks for one or more files
89
+ - If the comment applies to multiple chapters, read all relevant chapters and provide patches for each
90
+ - When adding citations ([@key]), also add the corresponding entry to the bibliography file
91
+ - Use ligarb Markdown features (admonitions, cross-references, index, etc.) where appropriate
92
+ - If no code change is needed (e.g. answering a question), omit the <patch> blocks
93
+ PROMPT
94
+ end
95
+
96
+ # Extract patches from the last assistant message and apply them.
97
+ # Supports cross-chapter patches via the file attribute.
98
+ # Uses transactional approach: all patches applied in memory first,
99
+ # then written to disk. On build failure, changes are rolled back.
100
+ def apply_patches(review)
101
+ patches = extract_patches(review)
102
+ if patches.empty?
103
+ hint = has_unmatched_patches?(review) ? " (patch tags found but format didn't match)" : ""
104
+ return { "error" => "No patches found in the conversation#{hint}" }
105
+ end
106
+
107
+ default_source = review.dig("context", "source_file")
108
+
109
+ # Group patches by target file
110
+ file_patches = {}
111
+ patches.each do |rel_path, old_text, new_text|
112
+ target = if rel_path && !rel_path.empty?
113
+ resolve_patch_file(rel_path)
114
+ else
115
+ default_source
116
+ end
117
+ next unless target
118
+
119
+ file_patches[target] ||= []
120
+ file_patches[target] << [old_text, new_text]
121
+ end
122
+
123
+ return { "error" => "No valid target files found for patches" } if file_patches.empty?
124
+
125
+ use_git = git_available?
126
+ target_files = file_patches.keys
127
+
128
+ # Check for uncommitted changes when git is available
129
+ if use_git
130
+ dirty = git_dirty_files(target_files)
131
+ unless dirty.empty?
132
+ return { "error" => "Cannot apply patches: uncommitted changes in #{dirty.join(", ")}" }
133
+ end
134
+ end
135
+
136
+ # Phase 1: Apply all patches in memory
137
+ applied = 0
138
+ total = patches.size
139
+ results = {} # file => new_content
140
+ backups = {} # file => original_content
141
+
142
+ file_patches.each do |file, file_patch_list|
143
+ next unless File.exist?(file)
144
+
145
+ content = File.read(file)
146
+ backups[file] = content
147
+
148
+ file_patch_list.each do |old_text, new_text|
149
+ if content.include?(old_text)
150
+ content = content.sub(old_text, new_text)
151
+ applied += 1
152
+ end
153
+ end
154
+
155
+ results[file] = content if content != backups[file]
156
+ end
157
+
158
+ return { "error" => "No patches matched the source files (0/#{total})" } if applied == 0
159
+
160
+ # Phase 2: Write all files at once
161
+ results.each { |file, content| File.write(file, content) }
162
+
163
+ # Phase 3: Rebuild
164
+ config_path = File.join(@config.base_dir, "book.yml")
165
+ require_relative "builder"
166
+ begin
167
+ Builder.new(config_path).build
168
+ rescue SystemExit => e
169
+ # Rollback on build failure
170
+ if use_git
171
+ git_rollback_files(results.keys)
172
+ else
173
+ backups.each { |file, content| File.write(file, content) if results.key?(file) }
174
+ end
175
+ return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed (rolled back): #{e.message}" }
176
+ end
177
+
178
+ # Phase 4: Commit on success
179
+ if use_git
180
+ git_commit_patches(results.keys, review)
181
+ end
182
+
183
+ { "text" => "Applied #{applied}/#{total} patch(es) and rebuilt." }
184
+ end
185
+
186
+ def uploaded_files_prompt_section(ctx)
187
+ files = ctx["uploaded_files"]
188
+ return "" unless files.is_a?(Array) && !files.empty?
189
+
190
+ lines = ["\nUploaded reference files (read these for context):"]
191
+ files.each do |f|
192
+ lines << "- #{f["label"]}: #{f["path"]}"
193
+ end
194
+ lines << ""
195
+ lines.join("\n")
196
+ end
197
+
198
+ # Parse <patch> blocks from assistant messages.
199
+ # Returns array of [file_path_or_nil, old_text, new_text].
200
+ def extract_patches(review)
201
+ (review["messages"] || [])
202
+ .select { |m| m["role"] == "assistant" }
203
+ .reverse
204
+ .each do |msg|
205
+ patches = msg["content"].scan(PATCH_RE)
206
+ return patches unless patches.empty?
207
+ end
208
+ []
209
+ end
210
+
211
+ # Check if assistant messages contain <patch> tags that didn't match PATCH_RE.
212
+ def has_unmatched_patches?(review)
213
+ (review["messages"] || [])
214
+ .select { |m| m["role"] == "assistant" }
215
+ .any? { |m| m["content"].include?("<patch") && m["content"].include?("</patch>") }
216
+ end
217
+
218
+ # Check if git is available in the project directory.
219
+ def git_available?
220
+ system("git", "rev-parse", "--git-dir",
221
+ chdir: @config.base_dir, out: File::NULL, err: File::NULL)
222
+ end
223
+
224
+ private
225
+
226
+ # Return list of target files that have uncommitted changes.
227
+ def git_dirty_files(files)
228
+ files.select do |file|
229
+ rel = relative_to_base(file)
230
+ next false unless rel
231
+ out, = Open3.capture2("git", "status", "--porcelain", "--", rel, chdir: @config.base_dir)
232
+ !out.strip.empty?
233
+ end
234
+ end
235
+
236
+ # Commit patched files with a message including review context.
237
+ def git_commit_patches(files, review)
238
+ rel_files = files.filter_map { |f| relative_to_base(f) }
239
+ return if rel_files.empty?
240
+
241
+ system("git", "add", "--", *rel_files, chdir: @config.base_dir)
242
+
243
+ message = build_commit_message(review, rel_files)
244
+ system("git", "commit", "-m", message, chdir: @config.base_dir,
245
+ out: File::NULL, err: File::NULL)
246
+ end
247
+
248
+ # Rollback files using git checkout.
249
+ def git_rollback_files(files)
250
+ rel_files = files.filter_map { |f| relative_to_base(f) }
251
+ return if rel_files.empty?
252
+
253
+ system("git", "checkout", "--", *rel_files, chdir: @config.base_dir,
254
+ out: File::NULL, err: File::NULL)
255
+ end
256
+
257
+ def build_commit_message(review, rel_files)
258
+ messages = review["messages"] || []
259
+ user_msg = messages.find { |m| m["role"] == "user" }&.dig("content").to_s
260
+ assistant_msg = messages.select { |m| m["role"] == "assistant" }.last&.dig("content").to_s
261
+
262
+ source = review.dig("context", "source_file") || "unknown"
263
+ source_rel = relative_to_base(source) || source
264
+
265
+ lines = ["[ligarb] Review: #{source_rel}"]
266
+ lines << ""
267
+ lines << "User: #{truncate_message(user_msg)}" unless user_msg.empty?
268
+ lines << "Claude: #{truncate_message(assistant_msg)}" unless assistant_msg.empty?
269
+ lines << ""
270
+ lines << "Files: #{rel_files.join(", ")}"
271
+ lines.join("\n")
272
+ end
273
+
274
+ def truncate_message(text, max_lines: 3, max_chars: 200)
275
+ truncated = text.lines.first(max_lines).join.strip
276
+ truncated = truncated[0, max_chars] + "..." if truncated.length > max_chars
277
+ truncated
278
+ end
279
+
280
+ def relative_to_base(file)
281
+ abs = File.expand_path(file)
282
+ base = File.expand_path(@config.base_dir)
283
+ return nil unless abs.start_with?(base + "/")
284
+ abs[(base.length + 1)..]
285
+ end
286
+
287
+ def which(cmd)
288
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
289
+ path = File.join(dir, cmd)
290
+ return path if File.executable?(path)
291
+ end
292
+ nil
293
+ end
294
+
295
+ # Resolve a relative path from a patch to an absolute path.
296
+ # Prevents path traversal outside base_dir.
297
+ def resolve_patch_file(rel_path)
298
+ absolute = File.expand_path(rel_path, @config.base_dir)
299
+ base_dir = File.expand_path(@config.base_dir)
300
+
301
+ # Reject paths that escape base_dir (e.g. "../../etc/passwd")
302
+ unless absolute.start_with?(base_dir + "/")
303
+ $stderr.puts "Warning: patch path '#{rel_path}' resolves outside project directory, skipping"
304
+ return nil
305
+ end
306
+
307
+ return absolute if File.exist?(absolute)
308
+
309
+ # Try matching by basename against all chapter paths
310
+ @config.all_file_paths.find { |p| p.end_with?("/#{rel_path}") || p == rel_path }
311
+ end
312
+ end
313
+ end
data/lib/ligarb/cli.rb CHANGED
@@ -17,15 +17,36 @@ module Ligarb
17
17
  Builder.new(config_path).build
18
18
  when "init"
19
19
  Initializer.new(args.first).run
20
+ when "serve"
21
+ config_paths = args.reject { |a| a.start_with?("--") }
22
+ config_paths = ["book.yml"] if config_paths.empty?
23
+ port_idx = args.index("--port")
24
+ port = port_idx ? args[port_idx + 1].to_i : 3000
25
+ abort "Error: port must be 1-65535" unless (1..65535).include?(port)
26
+ multi = args.include?("--multi")
27
+ require_relative "server"
28
+ Server.new(config_paths, port: port, multi: multi).start
29
+ when "librarium"
30
+ config_paths = Dir.glob("*/book.yml").sort
31
+ abort "Error: no */book.yml found in current directory" if config_paths.empty?
32
+ port_idx = args.index("--port")
33
+ port = port_idx ? args[port_idx + 1].to_i : 3000
34
+ abort "Error: port must be 1-65535" unless (1..65535).include?(port)
35
+ require_relative "server"
36
+ Server.new(config_paths, port: port, multi: true).start
20
37
  when "write"
21
- if args.delete("--init")
22
- require_relative "writer"
23
- Writer.init_brief(args.first)
24
- else
25
- brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
26
- no_build = args.include?("--no-build")
27
- require_relative "writer"
28
- Writer.new(brief_path, no_build: no_build).run
38
+ require_relative "writer"
39
+ begin
40
+ if args.delete("--init")
41
+ Writer.init_brief(args.first)
42
+ else
43
+ brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
44
+ no_build = args.include?("--no-build")
45
+ Writer.new(brief_path, no_build: no_build).run
46
+ end
47
+ rescue Writer::WriterError => e
48
+ $stderr.puts "Error: #{e.message}"
49
+ exit 1
29
50
  end
30
51
  when "--help", "-h", nil
31
52
  print_usage
@@ -47,6 +68,8 @@ module Ligarb
47
68
  Usage:
48
69
  ligarb init [DIRECTORY] Create a new book project
49
70
  ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
71
+ ligarb serve [CONFIG] Serve the book with live reload and review UI
72
+ ligarb librarium Serve all */book.yml as a multi-book library
50
73
  ligarb write [BRIEF] Generate a book with AI from brief.yml
51
74
  ligarb write --init [DIR] Create DIR/brief.yml template
52
75
  ligarb help Show detailed specification (for AI integration)
@@ -109,6 +132,38 @@ module Ligarb
109
132
  ligarb build [CONFIG] Build the HTML book.
110
133
  CONFIG defaults to 'book.yml' in the current directory.
111
134
 
135
+ ligarb serve [CONFIG...]
136
+ Start a local web server with live reload and review UI.
137
+ CONFIG defaults to 'book.yml' in the current directory.
138
+ Multiple CONFIG paths can be given to serve multiple books.
139
+ Options:
140
+ --port PORT Server port (default: 3000)
141
+ --multi Force multi-book mode (even with 1 CONFIG)
142
+ Single book mode (1 CONFIG, without --multi):
143
+ - Serves the built HTML book at http://localhost:PORT
144
+ Multi-book mode (2+ CONFIGs, or --multi):
145
+ - Top page (/) shows a book index with links
146
+ - Each book is served at /<directory-name>/
147
+ - "Write a new book" button on the index page to generate
148
+ a new book via AI (posts a brief, runs Writer in background)
149
+ - Example: ligarb serve */book.yml
150
+ Features:
151
+ - Injects a reload button that pulses when build output changes
152
+ - Injects a review UI for commenting on book text
153
+ - Review comments are saved to .ligarb/reviews/*.json
154
+ (in each book's directory)
155
+ - If 'claude' CLI is installed, comments are sent to Claude
156
+ for review suggestions, and approved changes are applied
157
+ to the source Markdown files and the book is rebuilt
158
+ - Review patches can span multiple chapters and the
159
+ bibliography file (Claude reads book.yml to find all files)
160
+
161
+ ligarb librarium Start a multi-book library server.
162
+ Automatically discovers */book.yml in the current directory.
163
+ Equivalent to: ligarb serve --multi */book.yml
164
+ Options:
165
+ --port PORT Server port (default: 3000)
166
+
112
167
  ligarb help Show this detailed specification.
113
168
 
114
169
  ligarb --help Show short usage summary.
@@ -255,9 +310,11 @@ module Ligarb
255
310
  and build/css/.
256
311
 
257
312
  ```ruby, ```python, etc. Syntax highlighting (highlight.js, BSD-3-Clause)
258
- ```mermaid Diagrams: flowcharts, sequence, class, etc.
313
+ ```mermaid Diagrams: flowcharts, sequence, bar/line/pie charts,
314
+ gantt, mindmap, etc.
259
315
  (mermaid, MIT)
260
316
  ```math LaTeX math equations (KaTeX, MIT)
317
+ ```functionplot Function graphs (function-plot + d3, MIT)
261
318
 
262
319
  These are rendered visually in the output HTML — use them freely.
263
320
 
@@ -278,6 +335,64 @@ module Ligarb
278
335
  Server-->>Client: Response
279
336
  ```
280
337
 
338
+ Mermaid example (bar chart):
339
+
340
+ ```mermaid
341
+ xychart
342
+ title "Monthly Sales"
343
+ x-axis ["Jan", "Feb", "Mar", "Apr", "May"]
344
+ y-axis "Revenue" 0 --> 500
345
+ bar [120, 230, 180, 350, 410]
346
+ line [120, 230, 180, 350, 410]
347
+ ```
348
+
349
+ Mermaid example (line chart, line only):
350
+
351
+ ```mermaid
352
+ xychart
353
+ title "Temperature"
354
+ x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
355
+ y-axis "°C" -5 --> 30
356
+ line [2, 4, 10, 16, 22, 26]
357
+ ```
358
+
359
+ Mermaid example (pie chart):
360
+
361
+ ```mermaid
362
+ pie title Browser Share
363
+ "Chrome" : 65
364
+ "Safari" : 19
365
+ "Firefox" : 4
366
+ "Other" : 12
367
+ ```
368
+
369
+ Mermaid example (gantt chart):
370
+
371
+ ```mermaid
372
+ gantt
373
+ title Project Plan
374
+ dateFormat YYYY-MM-DD
375
+ section Design
376
+ Requirements :a1, 2025-01-01, 14d
377
+ Architecture :a2, after a1, 10d
378
+ section Dev
379
+ Implementation :b1, after a2, 21d
380
+ Testing :b2, after b1, 14d
381
+ ```
382
+
383
+ Mermaid example (mindmap):
384
+
385
+ ```mermaid
386
+ mindmap
387
+ root((Project))
388
+ Frontend
389
+ React
390
+ CSS
391
+ Backend
392
+ API
393
+ Database
394
+ ```
395
+
281
396
  Math example (KaTeX, LaTeX syntax):
282
397
 
283
398
  ```math
@@ -295,6 +410,28 @@ module Ligarb
295
410
  - Content inside <code> and <pre> is not affected
296
411
  - The content is rendered with KaTeX (displayMode: false)
297
412
 
413
+ Function plot example:
414
+
415
+ ```functionplot
416
+ y = sin(x)
417
+ y = x^2 - 1
418
+ range: [-2pi, 2pi]
419
+ yrange: [-3, 3]
420
+ ```
421
+
422
+ Function plot syntax:
423
+ - y = <expr> Standard function (e.g. y = sin(x))
424
+ - r = <expr> Polar function (e.g. r = cos(2*theta))
425
+ - parametric: <x>, <y> Parametric curve (e.g. parametric: cos(t), sin(t))
426
+ - Bare expression Treated as y = <expr>
427
+ Options (one per line):
428
+ - range / xrange: [min, max] X-axis range (supports pi, e.g. [-2pi, 2pi])
429
+ - yrange: [min, max] Y-axis range
430
+ - width: <pixels> Plot width (default: 600)
431
+ - height: <pixels> Plot height (default: 400)
432
+ - title: <text> Plot title
433
+ - grid: true Show grid lines
434
+
298
435
  == Images ==
299
436
 
300
437
  Place image files in the 'images/' directory next to book.yml.
@@ -344,6 +481,67 @@ module Ligarb
344
481
 
345
482
  Clicking an index entry navigates to the exact location in the chapter.
346
483
 
484
+ == Bibliography ==
485
+
486
+ Cite references in the text using Markdown link syntax with #cite:
487
+
488
+ [Ruby](#cite:matz1995) Cite by key; rendered as Ruby[Matsumoto, 1995]
489
+
490
+ Define a bibliography data file in book.yml:
491
+
492
+ bibliography: references.yml # YAML format
493
+ bibliography: references.bib # BibTeX format
494
+
495
+ The format is auto-detected by file extension (.bib = BibTeX, otherwise YAML).
496
+
497
+ YAML format maps keys to reference data:
498
+
499
+ matz1995:
500
+ author: "Yukihiro Matsumoto"
501
+ title: "The Ruby Programming Language"
502
+ year: 1995
503
+ url: "https://www.ruby-lang.org"
504
+ publisher: "O'Reilly"
505
+ doi: "10.1234/example"
506
+
507
+ BibTeX format (.bib) is also supported:
508
+
509
+ @book{matz1995,
510
+ author = {Yukihiro Matsumoto},
511
+ title = {The Ruby Programming Language},
512
+ year = {1995},
513
+ publisher = {O'Reilly},
514
+ url = {https://www.ruby-lang.org}
515
+ }
516
+
517
+ BibTeX notes:
518
+ - Entry types (@book, @article, etc.) are preserved for formatting
519
+ - Field values can use {braces} or "quotes"
520
+ - Nested braces are supported one level deep ({The {Ruby} Language})
521
+ - Lines starting with % are comments
522
+
523
+ Supported fields (YAML and BibTeX):
524
+ author, title, year, url, publisher, journal, booktitle, volume,
525
+ number, pages, edition, doi, editor, note.
526
+
527
+ The bibliography section formats entries by type:
528
+ - book: Author. Title. Edition. Publisher, Year.
529
+ - article: Author. "Title". Journal, Volume(Number), Pages, Year.
530
+ - inproceedings: Author. "Title". In Booktitle, Pages, Year.
531
+ - other/YAML: Author. Title. Publisher/Journal, Volume, Pages, Year.
532
+
533
+ If url is present, the title becomes a link. If doi is present, a DOI link
534
+ is appended.
535
+
536
+ The citation is rendered as a superscript [author, year] link that navigates
537
+ to the "Bibliography" section at the end of the book. Hovering the link shows
538
+ the full reference. The bibliography section lists all cited entries sorted by
539
+ author and year.
540
+
541
+ A warning is printed and the citation is rendered as [key?] (highlighted in
542
+ red) if a cite key is not found in the bibliography file.
543
+ If no bibliography file is configured, cite markers are left as-is.
544
+
347
545
  == Custom CSS ==
348
546
 
349
547
  Add a 'style' field to book.yml to inject custom CSS:
data/lib/ligarb/config.rb CHANGED
@@ -14,7 +14,7 @@ module Ligarb
14
14
 
15
15
  attr_reader :title, :author, :language, :output_dir, :base_dir,
16
16
  :chapter_numbers, :structure, :style, :repository,
17
- :ai_generated, :footer
17
+ :ai_generated, :footer, :bibliography, :sources
18
18
 
19
19
  def initialize(path)
20
20
  @base_dir = File.dirname(File.expand_path(path))
@@ -31,6 +31,8 @@ module Ligarb
31
31
  @repository = data.fetch("repository", nil)
32
32
  @ai_generated = data.fetch("ai_generated", false)
33
33
  @footer = data.fetch("footer", nil)
34
+ @bibliography = data.fetch("bibliography", nil)
35
+ @sources = parse_sources(data.fetch("sources", []))
34
36
  @structure = parse_structure(data["chapters"])
35
37
  end
36
38
 
@@ -42,6 +44,10 @@ module Ligarb
42
44
  @style ? File.join(@base_dir, @style) : nil
43
45
  end
44
46
 
47
+ def bibliography_path
48
+ @bibliography ? File.join(@base_dir, @bibliography) : nil
49
+ end
50
+
45
51
  def appendix_label
46
52
  @language == "ja" ? "付録" : "Appendix"
47
53
  end
@@ -69,6 +75,24 @@ module Ligarb
69
75
 
70
76
  private
71
77
 
78
+ Source = Struct.new(:path, :label, keyword_init: true)
79
+
80
+ def parse_sources(entries)
81
+ return [] unless entries.is_a?(Array)
82
+
83
+ entries.map do |entry|
84
+ case entry
85
+ when String
86
+ Source.new(path: File.join(@base_dir, entry), label: File.basename(entry))
87
+ when Hash
88
+ path = entry["path"] or abort "Error: source entry missing 'path'"
89
+ Source.new(path: File.join(@base_dir, path), label: entry.fetch("label", File.basename(path)))
90
+ else
91
+ abort "Error: invalid source entry: #{entry.inspect}"
92
+ end
93
+ end
94
+ end
95
+
72
96
  def parse_structure(entries)
73
97
  entries.map do |entry|
74
98
  case entry
@@ -34,6 +34,7 @@ module Ligarb
34
34
  File.write(book_yml, generate_book_yml(title, chapter_paths))
35
35
  File.write(File.join(target, "images", ".gitkeep"), "")
36
36
 
37
+ init_git(target)
37
38
  print_success(target, chapter_paths)
38
39
  end
39
40
 
@@ -86,6 +87,25 @@ module Ligarb
86
87
  puts " Run 'ligarb build' to generate HTML"
87
88
  end
88
89
 
90
+ def init_git(target)
91
+ unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target)
92
+ write_gitignore(target)
93
+ system("git", "init", chdir: target)
94
+ puts "Initialized git repository"
95
+ end
96
+ end
97
+
98
+ def write_gitignore(target)
99
+ gitignore = File.join(target, ".gitignore")
100
+ return if File.exist?(gitignore)
101
+
102
+ File.write(gitignore, <<~IGNORE)
103
+ # Generated by ligarb - remove lines as needed
104
+ build/
105
+ .ligarb/
106
+ IGNORE
107
+ end
108
+
89
109
  def relative_path(target)
90
110
  if @directory && @directory != "."
91
111
  @directory.start_with?("/") ? @directory : "./#{@directory}"