ligarb 0.5.0 → 0.7.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.
@@ -6,14 +6,21 @@ require_relative "cli"
6
6
 
7
7
  module Ligarb
8
8
  class ClaudeRunner
9
- PATCH_RE = %r{<patch(?:\s+file="([^"]*)")?>\s*<<<\n(.*?)\n===\n(.*?)\n>>>\s*</patch>}m
9
+ PATCH_RE = %r{<patch(?:\s+file="([^"]*)")?>\s*<<<[ \t]*\r?\n(.*?)\r?\n===[ \t]*\r?\n(.*?)\r?\n>>>[ \t]*\s*</patch>}m
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
13
13
  end
14
14
 
15
15
  def installed?
16
- system("claude", "--version", out: File::NULL, err: File::NULL)
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
17
24
  end
18
25
 
19
26
  # Run claude -p with the given prompt. Returns the text response.
@@ -88,9 +95,14 @@ module Ligarb
88
95
 
89
96
  # Extract patches from the last assistant message and apply them.
90
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.
91
100
  def apply_patches(review)
92
101
  patches = extract_patches(review)
93
- return { "error" => "No patches found in the conversation" } if patches.empty?
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
94
106
 
95
107
  default_source = review.dig("context", "source_file")
96
108
 
@@ -110,37 +122,62 @@ module Ligarb
110
122
 
111
123
  return { "error" => "No valid target files found for patches" } if file_patches.empty?
112
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
113
137
  applied = 0
114
138
  total = patches.size
139
+ results = {} # file => new_content
140
+ backups = {} # file => original_content
115
141
 
116
142
  file_patches.each do |file, file_patch_list|
117
- unless File.exist?(file)
118
- next
119
- end
143
+ next unless File.exist?(file)
120
144
 
121
145
  content = File.read(file)
122
- changed = false
146
+ backups[file] = content
123
147
 
124
148
  file_patch_list.each do |old_text, new_text|
125
149
  if content.include?(old_text)
126
150
  content = content.sub(old_text, new_text)
127
151
  applied += 1
128
- changed = true
129
152
  end
130
153
  end
131
154
 
132
- File.write(file, content) if changed
155
+ results[file] = content if content != backups[file]
133
156
  end
134
157
 
135
158
  return { "error" => "No patches matched the source files (0/#{total})" } if applied == 0
136
159
 
137
- # Rebuild
160
+ # Phase 2: Write all files at once
161
+ results.each { |file, content| File.write(file, content) }
162
+
163
+ # Phase 3: Rebuild
138
164
  config_path = File.join(@config.base_dir, "book.yml")
139
165
  require_relative "builder"
140
166
  begin
141
167
  Builder.new(config_path).build
142
168
  rescue SystemExit => e
143
- return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed: #{e.message}" }
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)
144
181
  end
145
182
 
146
183
  { "text" => "Applied #{applied}/#{total} patch(es) and rebuilt." }
@@ -171,11 +208,102 @@ module Ligarb
171
208
  []
172
209
  end
173
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
+
174
224
  private
175
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
+
176
295
  # Resolve a relative path from a patch to an absolute path.
296
+ # Prevents path traversal outside base_dir.
177
297
  def resolve_patch_file(rel_path)
178
- absolute = File.join(@config.base_dir, 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
+
179
307
  return absolute if File.exist?(absolute)
180
308
 
181
309
  # Try matching by basename against all chapter paths
data/lib/ligarb/cli.rb CHANGED
@@ -22,8 +22,18 @@ module Ligarb
22
22
  config_paths = ["book.yml"] if config_paths.empty?
23
23
  port_idx = args.index("--port")
24
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")
25
27
  require_relative "server"
26
- Server.new(config_paths, port: port).start
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
27
37
  when "write"
28
38
  require_relative "writer"
29
39
  begin
@@ -59,6 +69,7 @@ module Ligarb
59
69
  ligarb init [DIRECTORY] Create a new book project
60
70
  ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
61
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
62
73
  ligarb write [BRIEF] Generate a book with AI from brief.yml
63
74
  ligarb write --init [DIR] Create DIR/brief.yml template
64
75
  ligarb help Show detailed specification (for AI integration)
@@ -79,6 +90,7 @@ module Ligarb
79
90
  repository (optional) GitHub repository URL for "Edit on GitHub" links
80
91
  ai_generated (optional) Mark as AI-generated (badge + meta tags, default: false)
81
92
  footer (optional) Custom text at bottom of each chapter
93
+ translations (optional) Map of lang => config path for multi-language builds
82
94
 
83
95
  Example:
84
96
  ligarb build
@@ -127,9 +139,10 @@ module Ligarb
127
139
  Multiple CONFIG paths can be given to serve multiple books.
128
140
  Options:
129
141
  --port PORT Server port (default: 3000)
130
- Single book mode (1 CONFIG):
142
+ --multi Force multi-book mode (even with 1 CONFIG)
143
+ Single book mode (1 CONFIG, without --multi):
131
144
  - Serves the built HTML book at http://localhost:PORT
132
- Multi-book mode (2+ CONFIGs):
145
+ Multi-book mode (2+ CONFIGs, or --multi):
133
146
  - Top page (/) shows a book index with links
134
147
  - Each book is served at /<directory-name>/
135
148
  - "Write a new book" button on the index page to generate
@@ -146,6 +159,12 @@ module Ligarb
146
159
  - Review patches can span multiple chapters and the
147
160
  bibliography file (Claude reads book.yml to find all files)
148
161
 
162
+ ligarb librarium Start a multi-book library server.
163
+ Automatically discovers */book.yml in the current directory.
164
+ Equivalent to: ligarb serve --multi */book.yml
165
+ Options:
166
+ --port PORT Server port (default: 3000)
167
+
149
168
  ligarb help Show this detailed specification.
150
169
 
151
170
  ligarb --help Show short usage summary.
@@ -176,8 +195,10 @@ module Ligarb
176
195
  footer: (optional) Custom text displayed at the bottom of each chapter.
177
196
  Overrides the default ai_generated disclaimer if both are set.
178
197
  Useful for copyright notices, disclaimers, or other per-chapter text.
198
+ translations: (optional) A mapping of language codes to config file paths.
199
+ Enables multi-language support. See "Translations" section below.
179
200
  chapters: (required) Book structure. An array that can contain:
180
- - A cover: a centered title/landing page
201
+ - A cover: the landing page shown when the book is opened
181
202
  - A string: a chapter Markdown file path (relative to book.yml)
182
203
  - A part: groups chapters under a titled section
183
204
  - An appendix: groups chapters with alphabetic numbering (A, B, C, ...)
@@ -186,7 +207,7 @@ module Ligarb
186
207
 
187
208
  1. Cover (object with 'cover' key):
188
209
  chapters:
189
- - cover: cover.md # Markdown file: displayed as centered title page
210
+ - cover: cover.md # Markdown file: landing page shown when the book is opened
190
211
  # Not shown in the TOC sidebar.
191
212
 
192
213
  2. Plain chapter (string):
@@ -292,9 +313,11 @@ module Ligarb
292
313
  and build/css/.
293
314
 
294
315
  ```ruby, ```python, etc. Syntax highlighting (highlight.js, BSD-3-Clause)
295
- ```mermaid Diagrams: flowcharts, sequence, class, etc.
316
+ ```mermaid Diagrams: flowcharts, sequence, bar/line/pie charts,
317
+ gantt, mindmap, etc.
296
318
  (mermaid, MIT)
297
319
  ```math LaTeX math equations (KaTeX, MIT)
320
+ ```functionplot Function graphs (function-plot + d3, MIT)
298
321
 
299
322
  These are rendered visually in the output HTML — use them freely.
300
323
 
@@ -315,6 +338,64 @@ module Ligarb
315
338
  Server-->>Client: Response
316
339
  ```
317
340
 
341
+ Mermaid example (bar chart):
342
+
343
+ ```mermaid
344
+ xychart
345
+ title "Monthly Sales"
346
+ x-axis ["Jan", "Feb", "Mar", "Apr", "May"]
347
+ y-axis "Revenue" 0 --> 500
348
+ bar [120, 230, 180, 350, 410]
349
+ line [120, 230, 180, 350, 410]
350
+ ```
351
+
352
+ Mermaid example (line chart, line only):
353
+
354
+ ```mermaid
355
+ xychart
356
+ title "Temperature"
357
+ x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
358
+ y-axis "°C" -5 --> 30
359
+ line [2, 4, 10, 16, 22, 26]
360
+ ```
361
+
362
+ Mermaid example (pie chart):
363
+
364
+ ```mermaid
365
+ pie title Browser Share
366
+ "Chrome" : 65
367
+ "Safari" : 19
368
+ "Firefox" : 4
369
+ "Other" : 12
370
+ ```
371
+
372
+ Mermaid example (gantt chart):
373
+
374
+ ```mermaid
375
+ gantt
376
+ title Project Plan
377
+ dateFormat YYYY-MM-DD
378
+ section Design
379
+ Requirements :a1, 2025-01-01, 14d
380
+ Architecture :a2, after a1, 10d
381
+ section Dev
382
+ Implementation :b1, after a2, 21d
383
+ Testing :b2, after b1, 14d
384
+ ```
385
+
386
+ Mermaid example (mindmap):
387
+
388
+ ```mermaid
389
+ mindmap
390
+ root((Project))
391
+ Frontend
392
+ React
393
+ CSS
394
+ Backend
395
+ API
396
+ Database
397
+ ```
398
+
318
399
  Math example (KaTeX, LaTeX syntax):
319
400
 
320
401
  ```math
@@ -332,6 +413,28 @@ module Ligarb
332
413
  - Content inside <code> and <pre> is not affected
333
414
  - The content is rendered with KaTeX (displayMode: false)
334
415
 
416
+ Function plot example:
417
+
418
+ ```functionplot
419
+ y = sin(x)
420
+ y = x^2 - 1
421
+ range: [-2pi, 2pi]
422
+ yrange: [-3, 3]
423
+ ```
424
+
425
+ Function plot syntax:
426
+ - y = <expr> Standard function (e.g. y = sin(x))
427
+ - r = <expr> Polar function (e.g. r = cos(2*theta))
428
+ - parametric: <x>, <y> Parametric curve (e.g. parametric: cos(t), sin(t))
429
+ - Bare expression Treated as y = <expr>
430
+ Options (one per line):
431
+ - range / xrange: [min, max] X-axis range (supports pi, e.g. [-2pi, 2pi])
432
+ - yrange: [min, max] Y-axis range
433
+ - width: <pixels> Plot width (default: 600)
434
+ - height: <pixels> Plot height (default: 400)
435
+ - title: <text> Plot title
436
+ - grid: true Show grid lines
437
+
335
438
  == Images ==
336
439
 
337
440
  Place image files in the 'images/' directory next to book.yml.
@@ -558,6 +661,8 @@ module Ligarb
558
661
  language: (optional) Language. Default: "ja".
559
662
  audience: (optional) Target audience (used in the prompt).
560
663
  notes: (optional) Additional instructions for Claude (free text).
664
+ sources: (optional) Reference files for AI context. Array of strings
665
+ or {path:, label:} objects. Paths relative to brief.yml.
561
666
  author: (optional) Passed through to book.yml.
562
667
  output_dir: (optional) Passed through to book.yml.
563
668
  chapter_numbers: (optional) Passed through to book.yml.
@@ -568,6 +673,56 @@ module Ligarb
568
673
  Example: 'ligarb write ruby_book/brief.yml' creates files in ruby_book/.
569
674
 
570
675
  Requires the 'claude' CLI to be installed.
676
+
677
+ == Translations (Multi-Language) ==
678
+
679
+ ligarb supports building the same book in multiple languages. A parent
680
+ config file (hub) defines shared settings and points to per-language
681
+ config files.
682
+
683
+ Hub config (book.yml):
684
+
685
+ repository: "https://github.com/user/repo"
686
+ ai_generated: true
687
+ translations:
688
+ ja: book.ja.yml
689
+ en: book.en.yml
690
+
691
+ Per-language config (book.ja.yml):
692
+
693
+ title: "マニュアル"
694
+ language: "ja"
695
+ chapters:
696
+ - chapters/ja/01-intro.md
697
+
698
+ Per-language config (book.en.yml):
699
+
700
+ title: "Manual"
701
+ language: "en"
702
+ chapters:
703
+ - chapters/en/01-intro.md
704
+
705
+ Building the hub builds all translations:
706
+
707
+ ligarb build book.yml # Builds all languages
708
+ ligarb build book.ja.yml # Builds only Japanese (standalone)
709
+
710
+ Inheritance rules:
711
+ - The hub's settings (repository, style, ai_generated, etc.) are
712
+ inherited by each per-language config as defaults.
713
+ - Per-language configs can override any inherited setting.
714
+ - title, language, and chapters are always per-language (required in
715
+ each per-language config).
716
+
717
+ The hub config does not need 'title' or 'chapters' fields — it only
718
+ needs 'translations'. If the hub has no 'chapters', it is treated
719
+ purely as a translations hub.
720
+
721
+ Language switcher:
722
+ - When built via the hub, each output HTML includes a language switcher
723
+ in the sidebar header (e.g. [JA | EN]).
724
+ - Links use relative paths between output directories.
725
+ - The current language is highlighted; others are clickable links.
571
726
  SPEC
572
727
  end
573
728
 
data/lib/ligarb/config.rb CHANGED
@@ -6,20 +6,56 @@ module Ligarb
6
6
  class Config
7
7
  REQUIRED_KEYS = %w[title chapters].freeze
8
8
 
9
+ # Keys that can be inherited from a parent (translations hub) config.
10
+ # output_dir is intentionally excluded — it always comes from the
11
+ # config file that was directly passed to `ligarb build`.
12
+ INHERITABLE_KEYS = %w[author language chapter_numbers style
13
+ repository ai_generated footer bibliography].freeze
14
+
9
15
  # Represents a structural entry in the book
10
16
  StructEntry = Struct.new(:type, :path, :children, keyword_init: true)
11
17
  # type: :chapter, :part, or :appendix_group
12
18
  # path: markdown file path (for :chapter and :part), nil for :appendix_group
13
19
  # children: array of StructEntry (for :part and :appendix_group)
14
20
 
21
+ # Represents a translation entry
22
+ # output_path: absolute path to the translation's build directory (for link generation)
23
+ TranslationEntry = Struct.new(:lang, :config_path, :title, :language, :output_path, keyword_init: true)
24
+
15
25
  attr_reader :title, :author, :language, :output_dir, :base_dir,
16
26
  :chapter_numbers, :structure, :style, :repository,
17
- :ai_generated, :footer, :bibliography, :sources
27
+ :ai_generated, :footer, :bibliography, :sources,
28
+ :translations
18
29
 
19
- def initialize(path)
30
+ def initialize(path, parent_data: nil)
20
31
  @base_dir = File.dirname(File.expand_path(path))
21
32
  data = YAML.safe_load_file(path)
22
33
 
34
+ # If this is a translations hub (has translations but no chapters), skip normal validation
35
+ if data.is_a?(Hash) && data.key?("translations") && !data.key?("chapters")
36
+ @translations_hub = true
37
+ @translations_data = data
38
+ load_translations_hub(data)
39
+ return
40
+ end
41
+
42
+ # Load inherit file if specified (for standalone builds of child configs)
43
+ if !parent_data && data.is_a?(Hash) && data.key?("inherit")
44
+ inherit_path = File.expand_path(data["inherit"], @base_dir)
45
+ if File.exist?(inherit_path)
46
+ parent_data = YAML.safe_load_file(inherit_path)
47
+ else
48
+ abort "Error: inherit config not found: #{inherit_path}"
49
+ end
50
+ end
51
+
52
+ # Merge parent (inheritable) keys as defaults
53
+ if parent_data
54
+ INHERITABLE_KEYS.each do |key|
55
+ data[key] = parent_data[key] if parent_data.key?(key) && !data.key?(key)
56
+ end
57
+ end
58
+
23
59
  validate!(data)
24
60
 
25
61
  @title = data["title"]
@@ -34,6 +70,13 @@ module Ligarb
34
70
  @bibliography = data.fetch("bibliography", nil)
35
71
  @sources = parse_sources(data.fetch("sources", []))
36
72
  @structure = parse_structure(data["chapters"])
73
+ @translations = []
74
+
75
+ load_translations(data, path) if data.key?("translations")
76
+ end
77
+
78
+ def translations_hub?
79
+ @translations_hub == true
37
80
  end
38
81
 
39
82
  def output_path
@@ -137,6 +180,66 @@ module Ligarb
137
180
  collect_chapter_paths(entries)
138
181
  end
139
182
 
183
+ def load_translations(data, config_path)
184
+ trans = data["translations"]
185
+ return unless trans.is_a?(Hash)
186
+
187
+ trans.each do |lang, rel_path|
188
+ abs_path = File.expand_path(rel_path, @base_dir)
189
+ unless File.exist?(abs_path)
190
+ abort "Error: translation config not found: #{abs_path}"
191
+ end
192
+ child_data = YAML.safe_load_file(abs_path)
193
+ # Merge parent keys for output_dir resolution
194
+ INHERITABLE_KEYS.each do |key|
195
+ child_data[key] = data[key] if data.key?(key) && !child_data.key?(key)
196
+ end
197
+ child_title = child_data["title"] || @title
198
+ child_lang = child_data.fetch("language", lang)
199
+ child_base = File.dirname(abs_path)
200
+ child_output = File.join(child_base, child_data.fetch("output_dir", "build"))
201
+ @translations << TranslationEntry.new(
202
+ lang: lang,
203
+ config_path: abs_path,
204
+ title: child_title,
205
+ language: child_lang,
206
+ output_path: child_output
207
+ )
208
+ end
209
+ end
210
+
211
+ def load_translations_hub(data)
212
+ @title = data.fetch("title", "")
213
+ @translations = []
214
+
215
+ trans = data["translations"]
216
+ return unless trans.is_a?(Hash)
217
+
218
+ trans.each do |lang, rel_path|
219
+ abs_path = File.expand_path(rel_path, @base_dir)
220
+ unless File.exist?(abs_path)
221
+ abort "Error: translation config not found: #{abs_path}"
222
+ end
223
+ child_data = YAML.safe_load_file(abs_path)
224
+ # Merge inheritable keys from hub as defaults
225
+ merged = child_data.dup
226
+ INHERITABLE_KEYS.each do |key|
227
+ merged[key] = data[key] if data.key?(key) && !merged.key?(key)
228
+ end
229
+ child_title = merged["title"] || @title
230
+ child_lang = merged.fetch("language", lang)
231
+ child_base = File.dirname(abs_path)
232
+ child_output = File.join(child_base, merged.fetch("output_dir", "build"))
233
+ @translations << TranslationEntry.new(
234
+ lang: lang,
235
+ config_path: abs_path,
236
+ title: child_title,
237
+ language: child_lang,
238
+ output_path: child_output
239
+ )
240
+ end
241
+ end
242
+
140
243
  def validate!(data)
141
244
  unless data.is_a?(Hash)
142
245
  abort "Error: book.yml must be a YAML mapping"
@@ -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}"