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