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/review_store.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
|
|
@@ -8,82 +9,103 @@ module Ligarb
|
|
|
8
9
|
class ReviewStore
|
|
9
10
|
def initialize(base_dir)
|
|
10
11
|
@dir = File.join(base_dir, ".ligarb", "reviews")
|
|
12
|
+
@mutex = Mutex.new
|
|
11
13
|
FileUtils.mkdir_p(@dir)
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def list
|
|
15
|
-
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
|
|
19
|
+
.compact
|
|
20
|
+
.sort_by { |r| r["created_at"] }
|
|
21
|
+
.map { |r| summary(r) }
|
|
22
|
+
end
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def get(id)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
get_unlocked(id)
|
|
28
|
+
end
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def create(context:, message:)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
id = SecureRandom.uuid
|
|
34
|
+
now = Time.now.utc.iso8601
|
|
35
|
+
|
|
36
|
+
review = {
|
|
37
|
+
"id" => id,
|
|
38
|
+
"status" => "open",
|
|
39
|
+
"created_at" => now,
|
|
40
|
+
"context" => context,
|
|
41
|
+
"messages" => [
|
|
42
|
+
{ "role" => "user", "content" => message, "timestamp" => now }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
write_json(id, review)
|
|
47
|
+
review
|
|
48
|
+
end
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
def add_message(id, role:, content:)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
review = get_unlocked(id)
|
|
54
|
+
return nil unless review
|
|
55
|
+
|
|
56
|
+
review["messages"] << {
|
|
57
|
+
"role" => role,
|
|
58
|
+
"content" => content,
|
|
59
|
+
"timestamp" => Time.now.utc.iso8601
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
write_json(id, review)
|
|
63
|
+
review
|
|
64
|
+
end
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
def update_context_files(id, files)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
review = get_unlocked(id)
|
|
70
|
+
return nil unless review
|
|
71
|
+
|
|
72
|
+
existing = review.dig("context", "uploaded_files") || []
|
|
73
|
+
review["context"]["uploaded_files"] = existing + files
|
|
74
|
+
write_json(id, review)
|
|
75
|
+
review
|
|
76
|
+
end
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
def update_status(id, status)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
review = get_unlocked(id)
|
|
82
|
+
return nil unless review
|
|
83
|
+
|
|
84
|
+
review["status"] = status
|
|
85
|
+
write_json(id, review)
|
|
86
|
+
review
|
|
87
|
+
end
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
def delete(id)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
path = file_path(id)
|
|
93
|
+
return false unless File.exist?(path)
|
|
94
|
+
File.delete(path)
|
|
95
|
+
true
|
|
96
|
+
end
|
|
83
97
|
end
|
|
84
98
|
|
|
85
99
|
private
|
|
86
100
|
|
|
101
|
+
def get_unlocked(id)
|
|
102
|
+
path = file_path(id)
|
|
103
|
+
return nil unless File.exist?(path)
|
|
104
|
+
review = read_json(path)
|
|
105
|
+
review["file_path"] = path if review
|
|
106
|
+
review
|
|
107
|
+
end
|
|
108
|
+
|
|
87
109
|
def file_path(id)
|
|
88
110
|
File.join(@dir, "#{id}.json")
|
|
89
111
|
end
|
data/lib/ligarb/server.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Ligarb
|
|
|
14
14
|
|
|
15
15
|
BookEntry = Struct.new(:slug, :config, :config_path, :build_dir, :store, :claude, keyword_init: true)
|
|
16
16
|
|
|
17
|
-
def initialize(config_paths, port: 3000)
|
|
17
|
+
def initialize(config_paths, port: 3000, multi: false)
|
|
18
18
|
@port = port
|
|
19
19
|
@assets_dir = File.join(File.dirname(__FILE__), "..", "..", "assets")
|
|
20
20
|
@sse_clients = [] # [[slug, queue], ...]
|
|
@@ -37,7 +37,11 @@ module Ligarb
|
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
@multi = @books.size > 1
|
|
40
|
+
@multi = multi || @books.size > 1
|
|
41
|
+
|
|
42
|
+
@operation_queue = Queue.new
|
|
43
|
+
@operation_thread = Thread.new { operation_worker }
|
|
44
|
+
@operation_thread.abort_on_exception = true
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
def start
|
|
@@ -49,6 +53,7 @@ module Ligarb
|
|
|
49
53
|
|
|
50
54
|
server = WEBrick::HTTPServer.new(
|
|
51
55
|
Port: @port,
|
|
56
|
+
BindAddress: "127.0.0.1",
|
|
52
57
|
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
|
|
53
58
|
AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]],
|
|
54
59
|
RequestBodyMaxSize: 50 * 1024 * 1024
|
|
@@ -77,6 +82,27 @@ module Ligarb
|
|
|
77
82
|
|
|
78
83
|
private
|
|
79
84
|
|
|
85
|
+
# ── Operation Queue ──
|
|
86
|
+
|
|
87
|
+
def operation_worker
|
|
88
|
+
loop do
|
|
89
|
+
job = @operation_queue.pop
|
|
90
|
+
job.call
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def enqueue_sync(&block)
|
|
95
|
+
result_queue = Queue.new
|
|
96
|
+
@operation_queue.push(-> {
|
|
97
|
+
result_queue.push(block.call)
|
|
98
|
+
})
|
|
99
|
+
result_queue.pop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def enqueue_async(&block)
|
|
103
|
+
@operation_queue.push(-> { block.call })
|
|
104
|
+
end
|
|
105
|
+
|
|
80
106
|
# ── SSE (Server-Sent Events) ──
|
|
81
107
|
|
|
82
108
|
def close_sse_clients
|
|
@@ -220,7 +246,8 @@ module Ligarb
|
|
|
220
246
|
file_path = File.join(build_dir, path)
|
|
221
247
|
file_path = File.realpath(file_path) rescue nil
|
|
222
248
|
|
|
223
|
-
|
|
249
|
+
real_build_dir = File.realpath(build_dir)
|
|
250
|
+
if file_path && (file_path.start_with?(real_build_dir + "/") || file_path == real_build_dir) && File.file?(file_path)
|
|
224
251
|
res.body = File.binread(file_path)
|
|
225
252
|
res["Content-Type"] = mime_type(file_path)
|
|
226
253
|
else
|
|
@@ -238,7 +265,7 @@ module Ligarb
|
|
|
238
265
|
|
|
239
266
|
api_base = @multi ? "/_ligarb/#{book.slug}" : "/_ligarb"
|
|
240
267
|
page_base = @multi ? "/#{book.slug}/" : "/"
|
|
241
|
-
config_tag = %(<script>window._ligarbAPI='#{api_base}';window._ligarbBase='#{page_base}';</script>)
|
|
268
|
+
config_tag = %(<script>window._ligarbAPI='#{escape_js_string(api_base)}';window._ligarbBase='#{escape_js_string(page_base)}';</script>)
|
|
242
269
|
|
|
243
270
|
js_tags = [config_tag] + %w[serve.js review.js].map { |f|
|
|
244
271
|
%(<script src="/_ligarb/assets/#{f}"></script>)
|
|
@@ -251,10 +278,13 @@ module Ligarb
|
|
|
251
278
|
|
|
252
279
|
def serve_index_page(res)
|
|
253
280
|
books_data = @books.values.sort_by { |b| b.config.title }.map { |book|
|
|
281
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
282
|
+
mtime = File.exist?(html_path) ? File.mtime(html_path).strftime("%Y-%m-%d %H:%M") : nil
|
|
254
283
|
{
|
|
255
284
|
slug: book.slug,
|
|
256
285
|
title: book.config.title,
|
|
257
286
|
author: book.config.author.to_s,
|
|
287
|
+
updated_at: mtime,
|
|
258
288
|
toc: build_toc(book)
|
|
259
289
|
}
|
|
260
290
|
}
|
|
@@ -267,7 +297,7 @@ module Ligarb
|
|
|
267
297
|
<head>
|
|
268
298
|
<meta charset="utf-8">
|
|
269
299
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
270
|
-
<title>ligarb</title>
|
|
300
|
+
<title>ligarb librarium</title>
|
|
271
301
|
<style>
|
|
272
302
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
273
303
|
body { font-family: system-ui, -apple-system, sans-serif; color: #333; height: 100vh; display: flex; flex-direction: column; }
|
|
@@ -280,6 +310,7 @@ module Ligarb
|
|
|
280
310
|
.idx-book.active { background: #eff6ff; border-left: 3px solid #2563eb; padding-left: 13px; }
|
|
281
311
|
.idx-book-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
282
312
|
.idx-book-author { font-size: 13px; color: #666; margin-top: 2px; }
|
|
313
|
+
.idx-book-updated { font-size: 12px; color: #999; margin-top: 2px; }
|
|
283
314
|
.idx-badge { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
|
|
284
315
|
.idx-badge-writing { background: #fff3e0; color: #e65100; animation: idx-pulse 1.5s ease-in-out infinite; }
|
|
285
316
|
.idx-badge-done { background: #e8f5e9; color: #2e7d32; }
|
|
@@ -321,7 +352,7 @@ module Ligarb
|
|
|
321
352
|
</style>
|
|
322
353
|
</head>
|
|
323
354
|
<body>
|
|
324
|
-
<div class="idx-header">
|
|
355
|
+
<div class="idx-header">Machinascripta</div>
|
|
325
356
|
<div class="idx-container">
|
|
326
357
|
<div class="idx-books">
|
|
327
358
|
<div class="idx-books-list" id="idx-books"></div>
|
|
@@ -363,7 +394,8 @@ module Ligarb
|
|
|
363
394
|
var job = writeJobs.find(function(j) { return j.slug === book.slug && j.status === 'done'; });
|
|
364
395
|
if (job) badge = ' <span class="idx-badge idx-badge-done">New!</span>';
|
|
365
396
|
el.innerHTML = '<div class="idx-book-title">' + esc(book.title) + badge + '</div>' +
|
|
366
|
-
(book.author ? '<div class="idx-book-author">' + esc(book.author) + '</div>' : '')
|
|
397
|
+
(book.author ? '<div class="idx-book-author">' + esc(book.author) + '</div>' : '') +
|
|
398
|
+
(book.updated_at ? '<div class="idx-book-updated">' + esc(book.updated_at) + '</div>' : '');
|
|
367
399
|
el.addEventListener('click', function() {
|
|
368
400
|
if (activeEl) activeEl.classList.remove('active');
|
|
369
401
|
el.classList.add('active');
|
|
@@ -606,8 +638,9 @@ module Ligarb
|
|
|
606
638
|
</html>
|
|
607
639
|
HTML
|
|
608
640
|
|
|
609
|
-
|
|
610
|
-
html = html.sub("
|
|
641
|
+
# Escape </ to prevent script injection inside <script> tag
|
|
642
|
+
html = html.sub("__BOOKS_JSON__", books_json.gsub("</", '<\/'))
|
|
643
|
+
html = html.sub("__WRITE_JOBS_JSON__", write_jobs_json.gsub("</", '<\/'))
|
|
611
644
|
res.body = html
|
|
612
645
|
res["Content-Type"] = "text/html; charset=utf-8"
|
|
613
646
|
end
|
|
@@ -668,7 +701,32 @@ module Ligarb
|
|
|
668
701
|
|
|
669
702
|
# ── API routing ──
|
|
670
703
|
|
|
704
|
+
def csrf_safe?(req)
|
|
705
|
+
return true if req.request_method == "GET"
|
|
706
|
+
|
|
707
|
+
origin = req["Origin"]
|
|
708
|
+
if origin && !origin.empty?
|
|
709
|
+
return origin == "http://localhost:#{@port}" ||
|
|
710
|
+
origin == "http://127.0.0.1:#{@port}"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
referer = req["Referer"]
|
|
714
|
+
if referer && !referer.empty?
|
|
715
|
+
uri = URI.parse(referer) rescue nil
|
|
716
|
+
return uri && %w[localhost 127.0.0.1].include?(uri.host) && uri.port == @port
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
false
|
|
720
|
+
end
|
|
721
|
+
|
|
671
722
|
def handle_api(req, res)
|
|
723
|
+
unless csrf_safe?(req)
|
|
724
|
+
res.status = 403
|
|
725
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
726
|
+
res.body = JSON.generate({ error: "CSRF check failed: request must originate from localhost" })
|
|
727
|
+
return
|
|
728
|
+
end
|
|
729
|
+
|
|
672
730
|
method = req.request_method
|
|
673
731
|
path = req.path.sub(%r{^/_ligarb}, "")
|
|
674
732
|
|
|
@@ -831,9 +889,51 @@ module Ligarb
|
|
|
831
889
|
end
|
|
832
890
|
|
|
833
891
|
log "Approve: applying patches for review #{id}"
|
|
834
|
-
result = book.claude.apply_patches(review)
|
|
835
892
|
|
|
836
|
-
|
|
893
|
+
result = enqueue_sync { book.claude.apply_patches(review) }
|
|
894
|
+
|
|
895
|
+
if result["error"] && result["error"].include?("rebuild failed")
|
|
896
|
+
# Build failed — feed the error back to Claude for a retry
|
|
897
|
+
log "Approve: build failed, requesting fix from Claude"
|
|
898
|
+
error_msg = result["error"]
|
|
899
|
+
book.store.add_message(id, role: "assistant", content: "Error: #{error_msg}")
|
|
900
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
901
|
+
|
|
902
|
+
retry_msg = "The patches were applied but the build failed with this error:\n\n" \
|
|
903
|
+
"#{error_msg}\n\n" \
|
|
904
|
+
"Please provide corrected patches that fix this issue."
|
|
905
|
+
book.store.add_message(id, role: "user", content: retry_msg)
|
|
906
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
907
|
+
|
|
908
|
+
# Re-run Claude with the updated conversation
|
|
909
|
+
review = book.store.get(id)
|
|
910
|
+
prompt = book.claude.review_prompt(review)
|
|
911
|
+
claude_result = book.claude.run(prompt)
|
|
912
|
+
|
|
913
|
+
if claude_result["error"]
|
|
914
|
+
log "Approve: retry Claude error: #{claude_result["error"]}"
|
|
915
|
+
book.store.add_message(id, role: "assistant", content: "Error: #{claude_result["error"]}")
|
|
916
|
+
book.store.update_status(id, "open")
|
|
917
|
+
else
|
|
918
|
+
log "Approve: Claude provided fix, retrying patches"
|
|
919
|
+
book.store.add_message(id, role: "assistant", content: claude_result["text"])
|
|
920
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
921
|
+
|
|
922
|
+
# Retry applying patches with the new response
|
|
923
|
+
review = book.store.get(id)
|
|
924
|
+
retry_result = enqueue_sync { book.claude.apply_patches(review) }
|
|
925
|
+
|
|
926
|
+
if retry_result["error"]
|
|
927
|
+
log "Approve: retry failed: #{retry_result["error"]}"
|
|
928
|
+
book.store.add_message(id, role: "assistant", content: "Retry failed: #{retry_result["error"]}")
|
|
929
|
+
book.store.update_status(id, "open")
|
|
930
|
+
else
|
|
931
|
+
log "Approve: retry succeeded: #{retry_result["text"]}"
|
|
932
|
+
book.store.add_message(id, role: "assistant", content: retry_result["text"])
|
|
933
|
+
book.store.update_status(id, "applied")
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
elsif result["error"]
|
|
837
937
|
log "Approve: error: #{result["error"]}"
|
|
838
938
|
book.store.add_message(id, role: "assistant", content: "Error: #{result["error"]}")
|
|
839
939
|
book.store.update_status(id, "open")
|
|
@@ -883,13 +983,17 @@ module Ligarb
|
|
|
883
983
|
log "Review: starting Claude review for #{review_id}"
|
|
884
984
|
begin
|
|
885
985
|
review = book.store.get(review_id)
|
|
886
|
-
|
|
986
|
+
next unless review
|
|
887
987
|
|
|
888
|
-
|
|
988
|
+
case book.claude.installed?
|
|
989
|
+
when :not_found
|
|
889
990
|
book.store.add_message(review_id, role: "assistant",
|
|
890
991
|
content: "Error: 'claude' command not found. Install Claude Code to enable AI reviews.")
|
|
891
|
-
|
|
892
|
-
|
|
992
|
+
next
|
|
993
|
+
when :version_failed
|
|
994
|
+
book.store.add_message(review_id, role: "assistant",
|
|
995
|
+
content: "Error: 'claude' command was found but failed to run. Check your Claude Code installation.")
|
|
996
|
+
next
|
|
893
997
|
end
|
|
894
998
|
|
|
895
999
|
prompt = book.claude.review_prompt(review)
|
|
@@ -968,8 +1072,8 @@ module Ligarb
|
|
|
968
1072
|
|
|
969
1073
|
sse_broadcast("write_updated", write_jobs_data, slug: nil)
|
|
970
1074
|
|
|
971
|
-
#
|
|
972
|
-
|
|
1075
|
+
# Run write job via operation queue (async)
|
|
1076
|
+
enqueue_async do
|
|
973
1077
|
begin
|
|
974
1078
|
log "Write: starting for '#{directory}' (title: #{title})"
|
|
975
1079
|
require_relative "writer"
|
|
@@ -980,6 +1084,16 @@ module Ligarb
|
|
|
980
1084
|
require_relative "builder"
|
|
981
1085
|
Builder.new(book_yml_path).build
|
|
982
1086
|
|
|
1087
|
+
# Git init + commit for the generated book
|
|
1088
|
+
unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target_dir)
|
|
1089
|
+
gitignore = File.join(target_dir, ".gitignore")
|
|
1090
|
+
File.write(gitignore, "# Generated by ligarb - remove lines as needed\nbuild/\n.ligarb/\n") unless File.exist?(gitignore)
|
|
1091
|
+
system("git", "init", chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1092
|
+
end
|
|
1093
|
+
system("git", "add", "-A", chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1094
|
+
system("git", "commit", "-m", "[ligarb] Generate book: #{title}",
|
|
1095
|
+
chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1096
|
+
|
|
983
1097
|
# Register the new book
|
|
984
1098
|
config = Config.new(book_yml_path)
|
|
985
1099
|
book = BookEntry.new(
|
|
@@ -1022,6 +1136,8 @@ module Ligarb
|
|
|
1022
1136
|
|
|
1023
1137
|
# ── Helpers ──
|
|
1024
1138
|
|
|
1139
|
+
MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 # 10MB per file
|
|
1140
|
+
|
|
1025
1141
|
def save_uploaded_files(files_array, base_dir)
|
|
1026
1142
|
return [] unless files_array.is_a?(Array) && !files_array.empty?
|
|
1027
1143
|
|
|
@@ -1033,13 +1149,19 @@ module Ligarb
|
|
|
1033
1149
|
data = file["data"].to_s
|
|
1034
1150
|
next if name.empty? || data.empty?
|
|
1035
1151
|
|
|
1152
|
+
decoded = Base64.decode64(data)
|
|
1153
|
+
if decoded.bytesize > MAX_UPLOAD_FILE_SIZE
|
|
1154
|
+
log "Upload rejected: #{name} exceeds #{MAX_UPLOAD_FILE_SIZE} bytes"
|
|
1155
|
+
next
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1036
1158
|
# Sanitize filename
|
|
1037
1159
|
safe_name = File.basename(name).gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
1038
1160
|
short_id = SecureRandom.hex(4)
|
|
1039
1161
|
filename = "#{short_id}-#{safe_name}"
|
|
1040
1162
|
dest = File.join(upload_dir, filename)
|
|
1041
1163
|
|
|
1042
|
-
File.binwrite(dest,
|
|
1164
|
+
File.binwrite(dest, decoded)
|
|
1043
1165
|
{ "path" => File.expand_path(dest), "label" => name }
|
|
1044
1166
|
end
|
|
1045
1167
|
end
|
|
@@ -1072,6 +1194,11 @@ module Ligarb
|
|
|
1072
1194
|
str.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
1073
1195
|
end
|
|
1074
1196
|
|
|
1197
|
+
def escape_js_string(str)
|
|
1198
|
+
str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub('"', '\\\\"')
|
|
1199
|
+
.gsub("\n", "\\n").gsub("\r", "\\r").gsub("</", '<\\/')
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1075
1202
|
def mime_type(path)
|
|
1076
1203
|
case File.extname(path).downcase
|
|
1077
1204
|
when ".html" then "text/html; charset=utf-8"
|
data/lib/ligarb/template.rb
CHANGED
|
@@ -35,12 +35,72 @@ module Ligarb
|
|
|
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
37
|
b.local_variable_set(:bibliography, bibliography)
|
|
38
|
+
b.local_variable_set(:multilang, false)
|
|
39
|
+
b.local_variable_set(:langs, [])
|
|
40
|
+
|
|
41
|
+
ERB.new(template, trim_mode: "-").result(b)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Render a single HTML with all languages, switchable via JS
|
|
45
|
+
def render_multilang(langs:, assets:, hub_data:)
|
|
46
|
+
css = File.read(@css_path)
|
|
47
|
+
template = File.read(@template_path)
|
|
48
|
+
|
|
49
|
+
first = langs.first
|
|
50
|
+
first_config = first[:config]
|
|
51
|
+
|
|
52
|
+
custom_css = if first_config.style_path && File.exist?(first_config.style_path)
|
|
53
|
+
File.read(first_config.style_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build per-language template data
|
|
57
|
+
lang_data = langs.map do |ld|
|
|
58
|
+
cfg = ld[:config]
|
|
59
|
+
{
|
|
60
|
+
lang: ld[:lang],
|
|
61
|
+
title: cfg.title,
|
|
62
|
+
author: cfg.author,
|
|
63
|
+
language: cfg.language,
|
|
64
|
+
chapters: ld[:chapters],
|
|
65
|
+
structure: ld[:structure],
|
|
66
|
+
repository: cfg.repository,
|
|
67
|
+
appendix_label: cfg.appendix_label,
|
|
68
|
+
ai_generated: cfg.ai_generated,
|
|
69
|
+
footer: cfg.effective_footer,
|
|
70
|
+
index_tree: build_index_tree(ld[:index_entries], ld[:chapters]),
|
|
71
|
+
bibliography: ld[:bibliography],
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
b = binding
|
|
76
|
+
# Use first language's values as defaults for shared template vars
|
|
77
|
+
b.local_variable_set(:title, first_config.title)
|
|
78
|
+
b.local_variable_set(:author, first_config.author)
|
|
79
|
+
b.local_variable_set(:language, first_config.language)
|
|
80
|
+
b.local_variable_set(:chapters, first[:chapters])
|
|
81
|
+
b.local_variable_set(:structure, first[:structure])
|
|
82
|
+
b.local_variable_set(:css, css)
|
|
83
|
+
b.local_variable_set(:custom_css, custom_css)
|
|
84
|
+
b.local_variable_set(:assets, assets)
|
|
85
|
+
b.local_variable_set(:repository, first_config.repository)
|
|
86
|
+
b.local_variable_set(:appendix_label, first_config.appendix_label)
|
|
87
|
+
b.local_variable_set(:ai_generated, first_config.ai_generated)
|
|
88
|
+
b.local_variable_set(:footer, first_config.effective_footer)
|
|
89
|
+
b.local_variable_set(:index_tree, build_index_tree(first[:index_entries], first[:chapters]))
|
|
90
|
+
b.local_variable_set(:bibliography, first[:bibliography])
|
|
91
|
+
b.local_variable_set(:multilang, true)
|
|
92
|
+
b.local_variable_set(:langs, lang_data)
|
|
38
93
|
|
|
39
94
|
ERB.new(template, trim_mode: "-").result(b)
|
|
40
95
|
end
|
|
41
96
|
|
|
42
97
|
private
|
|
43
98
|
|
|
99
|
+
# HTML-escape helper for ERB templates (available via binding)
|
|
100
|
+
def h(s)
|
|
101
|
+
ERB::Util.html_escape(s.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
44
104
|
# Build a sorted tree structure for the index.
|
|
45
105
|
# Returns: { "A" => [ { term: "Algorithm", refs: [...] },
|
|
46
106
|
# { term: "Array", refs: [...], children: [ { term: "sort", refs: [...] } ] } ],
|
data/lib/ligarb/version.rb
CHANGED
data/lib/ligarb/writer.rb
CHANGED
|
@@ -41,6 +41,8 @@ module Ligarb
|
|
|
41
41
|
Builder.new(book_yml_path).build
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
git_commit_initial(output_dir, brief["title"]) if git_available?(output_dir)
|
|
45
|
+
|
|
44
46
|
book_yml_path
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -90,15 +92,38 @@ module Ligarb
|
|
|
90
92
|
puts "Created #{path}"
|
|
91
93
|
puts "Created #{claude_md}" if created_claude_md
|
|
92
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
|
+
|
|
93
104
|
puts "Edit brief.yml, then run 'ligarb write#{brief_arg}' to generate the book."
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
private
|
|
97
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
|
+
|
|
98
119
|
def check_claude_installed!
|
|
99
|
-
|
|
120
|
+
claude_path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).find { |dir| File.executable?(File.join(dir, "claude")) }
|
|
121
|
+
unless claude_path
|
|
100
122
|
raise WriterError, "'claude' command not found. Install Claude Code first."
|
|
101
123
|
end
|
|
124
|
+
unless system("claude", "--version", out: File::NULL, err: File::NULL)
|
|
125
|
+
raise WriterError, "'claude' command was found but failed to run. Check your Claude Code installation."
|
|
126
|
+
end
|
|
102
127
|
end
|
|
103
128
|
|
|
104
129
|
def load_brief
|