ligarb 0.5.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.
- checksums.yaml +4 -4
- data/assets/review.css +17 -0
- data/assets/review.js +5 -2
- data/assets/serve.js +21 -0
- data/assets/style.css +36 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/chapter.rb +6 -2
- data/lib/ligarb/claude_runner.rb +140 -12
- data/lib/ligarb/cli.rb +104 -4
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/review_store.rb +71 -50
- data/lib/ligarb/server.rb +145 -18
- data/lib/ligarb/template.rb +5 -0
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +26 -1
- data/templates/book.html.erb +103 -21
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4aa971885dcefac0f440b02705cc3cb0adb88fcbf21c38cc23bed207887f30dc
|
|
4
|
+
data.tar.gz: 155c53e158b727d4e7c5ef5b012b980e32106e54e737c8c55b4f3953c08caeb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a04d10091801975a7029c762a81fed663b685710634a29320b8f6cd0a149297627e1c6fc2d28efd0c69d6fd44576ec5c535764b88fdcd62411ad0ec4f06dae35
|
|
7
|
+
data.tar.gz: b60a0faef98596347c5c4e6b25b2737a00a47b9998124a562d2d1b918648e1ad46560008e84426cc216d286d6e432d528e14d9f3f1ac3be595cab1463c5fbc54
|
data/assets/review.css
CHANGED
|
@@ -203,6 +203,23 @@
|
|
|
203
203
|
margin-top: 4px;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
.ligarb-file-path {
|
|
207
|
+
font-size: 10px;
|
|
208
|
+
color: var(--color-text-muted, #999);
|
|
209
|
+
margin-top: 2px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.ligarb-file-path summary {
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
opacity: 0.5;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.ligarb-file-path code {
|
|
218
|
+
font-size: 10px;
|
|
219
|
+
word-break: break-all;
|
|
220
|
+
user-select: all;
|
|
221
|
+
}
|
|
222
|
+
|
|
206
223
|
/* ── Messages ── */
|
|
207
224
|
|
|
208
225
|
.ligarb-messages {
|
data/assets/review.js
CHANGED
|
@@ -269,9 +269,11 @@
|
|
|
269
269
|
|
|
270
270
|
function renderReview(review) {
|
|
271
271
|
var ctx = review.context || {};
|
|
272
|
+
var filePath = review.file_path ? '<details class="ligarb-file-path"><summary>debug</summary><code>' + escapeHTML(review.file_path) + '</code></details>' : '';
|
|
272
273
|
panel.querySelector('.ligarb-context').innerHTML =
|
|
273
274
|
'<div class="ligarb-selected-text">"' + escapeHTML(ctx.selected_text || '') + '"</div>' +
|
|
274
|
-
'<div class="ligarb-meta">Chapter: ' + escapeHTML(ctx.chapter_slug || '') + '</div>'
|
|
275
|
+
'<div class="ligarb-meta">Chapter: ' + escapeHTML(ctx.chapter_slug || '') + '</div>' +
|
|
276
|
+
filePath;
|
|
275
277
|
|
|
276
278
|
var msgsEl = panel.querySelector('.ligarb-messages');
|
|
277
279
|
msgsEl.innerHTML = '';
|
|
@@ -331,7 +333,7 @@
|
|
|
331
333
|
var patches = '';
|
|
332
334
|
|
|
333
335
|
parts.forEach(function(part) {
|
|
334
|
-
var m = part.match(/<patch(?:\s+file="([^"]*)")
|
|
336
|
+
var m = part.match(/<patch(?:\s+file="([^"]*)")?>\s*<<<[ \t]*\r?\n([\s\S]*?)\r?\n===[ \t]*\r?\n([\s\S]*?)\r?\n>>>[ \t]*\s*<\/patch>/);
|
|
335
337
|
if (m) {
|
|
336
338
|
hasPatch = true;
|
|
337
339
|
var fileLabel = m[1] ? '<div class="ligarb-patch-file">' + escapeHTML(m[1]) + '</div>' : '';
|
|
@@ -505,6 +507,7 @@
|
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
body.innerHTML = '';
|
|
510
|
+
reviews.reverse();
|
|
508
511
|
reviews.forEach(function(r) {
|
|
509
512
|
var item = document.createElement('div');
|
|
510
513
|
item.className = 'ligarb-list-item ligarb-list-' + r.status;
|
data/assets/serve.js
CHANGED
|
@@ -53,6 +53,27 @@
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
window.scrollTo(0, scrollY);
|
|
56
|
+
|
|
57
|
+
// Re-initialize syntax highlighting and special blocks
|
|
58
|
+
if (typeof hljs !== 'undefined') hljs.highlightAll();
|
|
59
|
+
if (typeof mermaid !== 'undefined') {
|
|
60
|
+
var unrendered = oldMain.querySelectorAll('.mermaid:not([data-processed])');
|
|
61
|
+
if (unrendered.length > 0) mermaid.run({nodes: unrendered});
|
|
62
|
+
}
|
|
63
|
+
if (typeof katex !== 'undefined') {
|
|
64
|
+
oldMain.querySelectorAll('.math-block[data-math]').forEach(function(el) {
|
|
65
|
+
if (el.childNodes.length === 0) {
|
|
66
|
+
try { katex.render(el.getAttribute('data-math'), el, {displayMode: true, throwOnError: false}); }
|
|
67
|
+
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
oldMain.querySelectorAll('.math-inline[data-math]').forEach(function(el) {
|
|
71
|
+
if (el.childNodes.length === 0) {
|
|
72
|
+
try { katex.render(el.getAttribute('data-math'), el, {displayMode: false, throwOnError: false}); }
|
|
73
|
+
catch(e) { el.textContent = el.getAttribute('data-math'); }
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
56
77
|
}
|
|
57
78
|
refreshing = false;
|
|
58
79
|
hideReloadButton();
|
data/assets/style.css
CHANGED
|
@@ -447,6 +447,7 @@ mark.search-highlight {
|
|
|
447
447
|
--color-accent-light: #1c3a5c;
|
|
448
448
|
--color-code-bg: #0f172a;
|
|
449
449
|
--color-code-border: #2d3748;
|
|
450
|
+
--color-highlight: rgba(255, 243, 205, 0.15);
|
|
450
451
|
}
|
|
451
452
|
|
|
452
453
|
[data-theme="dark"] .admonition-note {
|
|
@@ -658,6 +659,17 @@ mark.search-highlight {
|
|
|
658
659
|
text-decoration: underline;
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
.ligarb-highlight {
|
|
663
|
+
border-radius: 4px;
|
|
664
|
+
padding: 4px 6px;
|
|
665
|
+
animation: ligarb-highlight-fade 3s ease-out forwards;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
@keyframes ligarb-highlight-fade {
|
|
669
|
+
0% { background: var(--color-highlight, #fff3cd); }
|
|
670
|
+
100% { background: transparent; }
|
|
671
|
+
}
|
|
672
|
+
|
|
661
673
|
.cite-ref {
|
|
662
674
|
font-size: 0.8em;
|
|
663
675
|
}
|
|
@@ -815,3 +827,27 @@ mark.search-highlight {
|
|
|
815
827
|
}
|
|
816
828
|
}
|
|
817
829
|
}
|
|
830
|
+
|
|
831
|
+
/* === Mermaid source toggle === */
|
|
832
|
+
details.mermaid-source {
|
|
833
|
+
margin-top: 0.3em;
|
|
834
|
+
font-size: 0.8em;
|
|
835
|
+
}
|
|
836
|
+
details.mermaid-source summary {
|
|
837
|
+
cursor: pointer;
|
|
838
|
+
color: #888;
|
|
839
|
+
user-select: none;
|
|
840
|
+
}
|
|
841
|
+
details.mermaid-source pre {
|
|
842
|
+
margin-top: 0.3em;
|
|
843
|
+
padding: 0.5em;
|
|
844
|
+
background: #f5f5f5;
|
|
845
|
+
border: 1px solid #ddd;
|
|
846
|
+
border-radius: 4px;
|
|
847
|
+
overflow-x: auto;
|
|
848
|
+
white-space: pre-wrap;
|
|
849
|
+
}
|
|
850
|
+
.dark-mode details.mermaid-source pre {
|
|
851
|
+
background: #2a2a2a;
|
|
852
|
+
border-color: #444;
|
|
853
|
+
}
|
data/lib/ligarb/asset_manager.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Ligarb
|
|
|
9
9
|
class AssetManager
|
|
10
10
|
ASSETS = {
|
|
11
11
|
highlight: {
|
|
12
|
-
fence_pattern: /language-(?!mermaid|math)(\w+)/,
|
|
12
|
+
fence_pattern: /language-(?!mermaid|math|functionplot)(\w+)/,
|
|
13
13
|
files: {
|
|
14
14
|
"js/highlight.min.js" => "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js",
|
|
15
15
|
"css/highlight.css" => "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css",
|
|
@@ -28,6 +28,13 @@ module Ligarb
|
|
|
28
28
|
"css/katex.min.css" => "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css",
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
|
+
functionplot: {
|
|
32
|
+
fence_pattern: /class="functionplot"/,
|
|
33
|
+
files: {
|
|
34
|
+
"js/d3.min.js" => "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js",
|
|
35
|
+
"js/function-plot.min.js" => "https://cdn.jsdelivr.net/npm/function-plot@1/dist/function-plot.js",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
31
38
|
}.freeze
|
|
32
39
|
|
|
33
40
|
def initialize(output_path)
|
|
@@ -60,6 +67,8 @@ module Ligarb
|
|
|
60
67
|
|
|
61
68
|
private
|
|
62
69
|
|
|
70
|
+
MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5MB
|
|
71
|
+
|
|
63
72
|
def download(url, dest)
|
|
64
73
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
65
74
|
$stderr.print "Downloading #{File.basename(dest)}... "
|
|
@@ -68,6 +77,9 @@ module Ligarb
|
|
|
68
77
|
response = fetch_with_redirects(uri)
|
|
69
78
|
|
|
70
79
|
if response.is_a?(Net::HTTPSuccess)
|
|
80
|
+
if response.body.bytesize > MAX_ASSET_SIZE
|
|
81
|
+
abort "Error: downloaded file too large: #{File.basename(dest)} (#{response.body.bytesize} bytes, limit #{MAX_ASSET_SIZE})"
|
|
82
|
+
end
|
|
71
83
|
File.write(dest, response.body)
|
|
72
84
|
$stderr.puts "done"
|
|
73
85
|
else
|
|
@@ -77,11 +89,14 @@ module Ligarb
|
|
|
77
89
|
|
|
78
90
|
def fetch_with_redirects(uri, limit = 5)
|
|
79
91
|
raise "Too many redirects" if limit == 0
|
|
92
|
+
raise "Only HTTPS URLs are supported for asset downloads" unless uri.scheme == "https"
|
|
80
93
|
|
|
81
94
|
response = Net::HTTP.get_response(uri)
|
|
82
95
|
case response
|
|
83
96
|
when Net::HTTPRedirection
|
|
84
|
-
|
|
97
|
+
new_uri = URI(response["location"])
|
|
98
|
+
raise "Redirect to non-HTTPS URL: #{new_uri}" unless new_uri.scheme == "https"
|
|
99
|
+
fetch_with_redirects(new_uri, limit - 1)
|
|
85
100
|
else
|
|
86
101
|
response
|
|
87
102
|
end
|
data/lib/ligarb/chapter.rb
CHANGED
|
@@ -166,14 +166,18 @@ module Ligarb
|
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def convert_special_code_blocks(html)
|
|
169
|
-
html.gsub(%r{<pre><code class="language-(mermaid|math)">(.*?)</code></pre>}m) do
|
|
169
|
+
html.gsub(%r{<pre><code class="language-(mermaid|math|functionplot)">(.*?)</code></pre>}m) do
|
|
170
170
|
lang = $1
|
|
171
171
|
raw = decode_entities($2)
|
|
172
172
|
case lang
|
|
173
173
|
when "mermaid"
|
|
174
|
-
|
|
174
|
+
escaped = raw.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
175
|
+
%(<div class="mermaid">\n#{raw}</div>) +
|
|
176
|
+
%(<details class="mermaid-source"><summary>mermaid source</summary><pre>#{escaped}</pre></details>)
|
|
175
177
|
when "math"
|
|
176
178
|
%(<div class="math-block" data-math="#{encode_attr(raw)}"></div>)
|
|
179
|
+
when "functionplot"
|
|
180
|
+
%(<div class="functionplot" data-plot="#{encode_attr(raw)}"></div>)
|
|
177
181
|
end
|
|
178
182
|
end
|
|
179
183
|
end
|
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)
|
|
@@ -127,9 +138,10 @@ module Ligarb
|
|
|
127
138
|
Multiple CONFIG paths can be given to serve multiple books.
|
|
128
139
|
Options:
|
|
129
140
|
--port PORT Server port (default: 3000)
|
|
130
|
-
|
|
141
|
+
--multi Force multi-book mode (even with 1 CONFIG)
|
|
142
|
+
Single book mode (1 CONFIG, without --multi):
|
|
131
143
|
- Serves the built HTML book at http://localhost:PORT
|
|
132
|
-
Multi-book mode (2+ CONFIGs):
|
|
144
|
+
Multi-book mode (2+ CONFIGs, or --multi):
|
|
133
145
|
- Top page (/) shows a book index with links
|
|
134
146
|
- Each book is served at /<directory-name>/
|
|
135
147
|
- "Write a new book" button on the index page to generate
|
|
@@ -146,6 +158,12 @@ module Ligarb
|
|
|
146
158
|
- Review patches can span multiple chapters and the
|
|
147
159
|
bibliography file (Claude reads book.yml to find all files)
|
|
148
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
|
+
|
|
149
167
|
ligarb help Show this detailed specification.
|
|
150
168
|
|
|
151
169
|
ligarb --help Show short usage summary.
|
|
@@ -292,9 +310,11 @@ module Ligarb
|
|
|
292
310
|
and build/css/.
|
|
293
311
|
|
|
294
312
|
```ruby, ```python, etc. Syntax highlighting (highlight.js, BSD-3-Clause)
|
|
295
|
-
```mermaid Diagrams: flowcharts, sequence,
|
|
313
|
+
```mermaid Diagrams: flowcharts, sequence, bar/line/pie charts,
|
|
314
|
+
gantt, mindmap, etc.
|
|
296
315
|
(mermaid, MIT)
|
|
297
316
|
```math LaTeX math equations (KaTeX, MIT)
|
|
317
|
+
```functionplot Function graphs (function-plot + d3, MIT)
|
|
298
318
|
|
|
299
319
|
These are rendered visually in the output HTML — use them freely.
|
|
300
320
|
|
|
@@ -315,6 +335,64 @@ module Ligarb
|
|
|
315
335
|
Server-->>Client: Response
|
|
316
336
|
```
|
|
317
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
|
+
|
|
318
396
|
Math example (KaTeX, LaTeX syntax):
|
|
319
397
|
|
|
320
398
|
```math
|
|
@@ -332,6 +410,28 @@ module Ligarb
|
|
|
332
410
|
- Content inside <code> and <pre> is not affected
|
|
333
411
|
- The content is rendered with KaTeX (displayMode: false)
|
|
334
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
|
+
|
|
335
435
|
== Images ==
|
|
336
436
|
|
|
337
437
|
Place image files in the 'images/' directory next to book.yml.
|
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}"
|