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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e697032ff94ff3c4a910343f29d6a977bd386afaa92dbcf4dbb0ca0108e70b2
4
- data.tar.gz: d142ecba5fb0cb7287f2206e31309c40dff8016b216f79053669f5fa8bc59c47
3
+ metadata.gz: 4aa971885dcefac0f440b02705cc3cb0adb88fcbf21c38cc23bed207887f30dc
4
+ data.tar.gz: 155c53e158b727d4e7c5ef5b012b980e32106e54e737c8c55b4f3953c08caeb2
5
5
  SHA512:
6
- metadata.gz: 6a24c126ae03426cd2361885244882a08b5e666cd112fa77b437225a48d954adb0ec2ea59d3c620b317a9549ea78efa17bfcde367c8aed9bd69be79f6b7f45b9
7
- data.tar.gz: b77a95db5ec9f3541349d999b9c936ddad7b65df844a3ec15f0059698e05f8973cabde8ac2b3fe60598acfa43ab866c70162bee0c1ce46bc7350be41ec195253
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="([^"]*)")?>[\s\S]*?<<<\n([\s\S]*?)\n===\n([\s\S]*?)\n>>>\s*<\/patch>/);
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
+ }
@@ -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
- fetch_with_redirects(URI(response["location"]), limit - 1)
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
@@ -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
- %(<div class="mermaid">\n#{raw}</div>)
174
+ escaped = raw.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
@@ -6,14 +6,21 @@ require_relative "cli"
6
6
 
7
7
  module Ligarb
8
8
  class ClaudeRunner
9
- PATCH_RE = %r{<patch(?:\s+file="([^"]*)")?>\s*<<<\n(.*?)\n===\n(.*?)\n>>>\s*</patch>}m
9
+ PATCH_RE = %r{<patch(?:\s+file="([^"]*)")?>\s*<<<[ \t]*\r?\n(.*?)\r?\n===[ \t]*\r?\n(.*?)\r?\n>>>[ \t]*\s*</patch>}m
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
13
13
  end
14
14
 
15
15
  def installed?
16
- system("claude", "--version", out: File::NULL, err: File::NULL)
16
+ claude_path = which("claude")
17
+ return :not_found unless claude_path
18
+
19
+ if system(claude_path, "--version", out: File::NULL, err: File::NULL)
20
+ true
21
+ else
22
+ :version_failed
23
+ end
17
24
  end
18
25
 
19
26
  # Run claude -p with the given prompt. Returns the text response.
@@ -88,9 +95,14 @@ module Ligarb
88
95
 
89
96
  # Extract patches from the last assistant message and apply them.
90
97
  # Supports cross-chapter patches via the file attribute.
98
+ # Uses transactional approach: all patches applied in memory first,
99
+ # then written to disk. On build failure, changes are rolled back.
91
100
  def apply_patches(review)
92
101
  patches = extract_patches(review)
93
- return { "error" => "No patches found in the conversation" } if patches.empty?
102
+ if patches.empty?
103
+ hint = has_unmatched_patches?(review) ? " (patch tags found but format didn't match)" : ""
104
+ return { "error" => "No patches found in the conversation#{hint}" }
105
+ end
94
106
 
95
107
  default_source = review.dig("context", "source_file")
96
108
 
@@ -110,37 +122,62 @@ module Ligarb
110
122
 
111
123
  return { "error" => "No valid target files found for patches" } if file_patches.empty?
112
124
 
125
+ use_git = git_available?
126
+ target_files = file_patches.keys
127
+
128
+ # Check for uncommitted changes when git is available
129
+ if use_git
130
+ dirty = git_dirty_files(target_files)
131
+ unless dirty.empty?
132
+ return { "error" => "Cannot apply patches: uncommitted changes in #{dirty.join(", ")}" }
133
+ end
134
+ end
135
+
136
+ # Phase 1: Apply all patches in memory
113
137
  applied = 0
114
138
  total = patches.size
139
+ results = {} # file => new_content
140
+ backups = {} # file => original_content
115
141
 
116
142
  file_patches.each do |file, file_patch_list|
117
- unless File.exist?(file)
118
- next
119
- end
143
+ next unless File.exist?(file)
120
144
 
121
145
  content = File.read(file)
122
- changed = false
146
+ backups[file] = content
123
147
 
124
148
  file_patch_list.each do |old_text, new_text|
125
149
  if content.include?(old_text)
126
150
  content = content.sub(old_text, new_text)
127
151
  applied += 1
128
- changed = true
129
152
  end
130
153
  end
131
154
 
132
- File.write(file, content) if changed
155
+ results[file] = content if content != backups[file]
133
156
  end
134
157
 
135
158
  return { "error" => "No patches matched the source files (0/#{total})" } if applied == 0
136
159
 
137
- # Rebuild
160
+ # Phase 2: Write all files at once
161
+ results.each { |file, content| File.write(file, content) }
162
+
163
+ # Phase 3: Rebuild
138
164
  config_path = File.join(@config.base_dir, "book.yml")
139
165
  require_relative "builder"
140
166
  begin
141
167
  Builder.new(config_path).build
142
168
  rescue SystemExit => e
143
- return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed: #{e.message}" }
169
+ # Rollback on build failure
170
+ if use_git
171
+ git_rollback_files(results.keys)
172
+ else
173
+ backups.each { |file, content| File.write(file, content) if results.key?(file) }
174
+ end
175
+ return { "error" => "Applied #{applied}/#{total} patch(es) but rebuild failed (rolled back): #{e.message}" }
176
+ end
177
+
178
+ # Phase 4: Commit on success
179
+ if use_git
180
+ git_commit_patches(results.keys, review)
144
181
  end
145
182
 
146
183
  { "text" => "Applied #{applied}/#{total} patch(es) and rebuilt." }
@@ -171,11 +208,102 @@ module Ligarb
171
208
  []
172
209
  end
173
210
 
211
+ # Check if assistant messages contain <patch> tags that didn't match PATCH_RE.
212
+ def has_unmatched_patches?(review)
213
+ (review["messages"] || [])
214
+ .select { |m| m["role"] == "assistant" }
215
+ .any? { |m| m["content"].include?("<patch") && m["content"].include?("</patch>") }
216
+ end
217
+
218
+ # Check if git is available in the project directory.
219
+ def git_available?
220
+ system("git", "rev-parse", "--git-dir",
221
+ chdir: @config.base_dir, out: File::NULL, err: File::NULL)
222
+ end
223
+
174
224
  private
175
225
 
226
+ # Return list of target files that have uncommitted changes.
227
+ def git_dirty_files(files)
228
+ files.select do |file|
229
+ rel = relative_to_base(file)
230
+ next false unless rel
231
+ out, = Open3.capture2("git", "status", "--porcelain", "--", rel, chdir: @config.base_dir)
232
+ !out.strip.empty?
233
+ end
234
+ end
235
+
236
+ # Commit patched files with a message including review context.
237
+ def git_commit_patches(files, review)
238
+ rel_files = files.filter_map { |f| relative_to_base(f) }
239
+ return if rel_files.empty?
240
+
241
+ system("git", "add", "--", *rel_files, chdir: @config.base_dir)
242
+
243
+ message = build_commit_message(review, rel_files)
244
+ system("git", "commit", "-m", message, chdir: @config.base_dir,
245
+ out: File::NULL, err: File::NULL)
246
+ end
247
+
248
+ # Rollback files using git checkout.
249
+ def git_rollback_files(files)
250
+ rel_files = files.filter_map { |f| relative_to_base(f) }
251
+ return if rel_files.empty?
252
+
253
+ system("git", "checkout", "--", *rel_files, chdir: @config.base_dir,
254
+ out: File::NULL, err: File::NULL)
255
+ end
256
+
257
+ def build_commit_message(review, rel_files)
258
+ messages = review["messages"] || []
259
+ user_msg = messages.find { |m| m["role"] == "user" }&.dig("content").to_s
260
+ assistant_msg = messages.select { |m| m["role"] == "assistant" }.last&.dig("content").to_s
261
+
262
+ source = review.dig("context", "source_file") || "unknown"
263
+ source_rel = relative_to_base(source) || source
264
+
265
+ lines = ["[ligarb] Review: #{source_rel}"]
266
+ lines << ""
267
+ lines << "User: #{truncate_message(user_msg)}" unless user_msg.empty?
268
+ lines << "Claude: #{truncate_message(assistant_msg)}" unless assistant_msg.empty?
269
+ lines << ""
270
+ lines << "Files: #{rel_files.join(", ")}"
271
+ lines.join("\n")
272
+ end
273
+
274
+ def truncate_message(text, max_lines: 3, max_chars: 200)
275
+ truncated = text.lines.first(max_lines).join.strip
276
+ truncated = truncated[0, max_chars] + "..." if truncated.length > max_chars
277
+ truncated
278
+ end
279
+
280
+ def relative_to_base(file)
281
+ abs = File.expand_path(file)
282
+ base = File.expand_path(@config.base_dir)
283
+ return nil unless abs.start_with?(base + "/")
284
+ abs[(base.length + 1)..]
285
+ end
286
+
287
+ def which(cmd)
288
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
289
+ path = File.join(dir, cmd)
290
+ return path if File.executable?(path)
291
+ end
292
+ nil
293
+ end
294
+
176
295
  # Resolve a relative path from a patch to an absolute path.
296
+ # Prevents path traversal outside base_dir.
177
297
  def resolve_patch_file(rel_path)
178
- absolute = File.join(@config.base_dir, rel_path)
298
+ absolute = File.expand_path(rel_path, @config.base_dir)
299
+ base_dir = File.expand_path(@config.base_dir)
300
+
301
+ # Reject paths that escape base_dir (e.g. "../../etc/passwd")
302
+ unless absolute.start_with?(base_dir + "/")
303
+ $stderr.puts "Warning: patch path '#{rel_path}' resolves outside project directory, skipping"
304
+ return nil
305
+ end
306
+
179
307
  return absolute if File.exist?(absolute)
180
308
 
181
309
  # Try matching by basename against all chapter paths
data/lib/ligarb/cli.rb CHANGED
@@ -22,8 +22,18 @@ module Ligarb
22
22
  config_paths = ["book.yml"] if config_paths.empty?
23
23
  port_idx = args.index("--port")
24
24
  port = port_idx ? args[port_idx + 1].to_i : 3000
25
+ abort "Error: port must be 1-65535" unless (1..65535).include?(port)
26
+ multi = args.include?("--multi")
25
27
  require_relative "server"
26
- Server.new(config_paths, port: port).start
28
+ Server.new(config_paths, port: port, multi: multi).start
29
+ when "librarium"
30
+ config_paths = Dir.glob("*/book.yml").sort
31
+ abort "Error: no */book.yml found in current directory" if config_paths.empty?
32
+ port_idx = args.index("--port")
33
+ port = port_idx ? args[port_idx + 1].to_i : 3000
34
+ abort "Error: port must be 1-65535" unless (1..65535).include?(port)
35
+ require_relative "server"
36
+ Server.new(config_paths, port: port, multi: true).start
27
37
  when "write"
28
38
  require_relative "writer"
29
39
  begin
@@ -59,6 +69,7 @@ module Ligarb
59
69
  ligarb init [DIRECTORY] Create a new book project
60
70
  ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
61
71
  ligarb serve [CONFIG] Serve the book with live reload and review UI
72
+ ligarb librarium Serve all */book.yml as a multi-book library
62
73
  ligarb write [BRIEF] Generate a book with AI from brief.yml
63
74
  ligarb write --init [DIR] Create DIR/brief.yml template
64
75
  ligarb help Show detailed specification (for AI integration)
@@ -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
- Single book mode (1 CONFIG):
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, class, etc.
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.
@@ -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}"