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.
- checksums.yaml +4 -4
- data/assets/review.css +17 -0
- data/assets/review.js +5 -2
- data/assets/serve.js +33 -0
- data/assets/style.css +132 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/builder.rb +82 -10
- data/lib/ligarb/chapter.rb +11 -5
- data/lib/ligarb/claude_runner.rb +140 -12
- data/lib/ligarb/cli.rb +161 -6
- data/lib/ligarb/config.rb +105 -2
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/review_store.rb +72 -50
- data/lib/ligarb/server.rb +145 -18
- data/lib/ligarb/template.rb +60 -0
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +26 -1
- data/templates/book.html.erb +598 -29
- metadata +2 -2
data/lib/ligarb/claude_runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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"
|
data/lib/ligarb/initializer.rb
CHANGED
|
@@ -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}"
|