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.
@@ -0,0 +1,1218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "json"
5
+ require "base64"
6
+ require "securerandom"
7
+ require_relative "config"
8
+ require_relative "review_store"
9
+ require_relative "claude_runner"
10
+
11
+ module Ligarb
12
+ class Server
13
+ INJECTED_ASSETS = %w[serve.js review.js review.css].freeze
14
+
15
+ BookEntry = Struct.new(:slug, :config, :config_path, :build_dir, :store, :claude, keyword_init: true)
16
+
17
+ def initialize(config_paths, port: 3000, multi: false)
18
+ @port = port
19
+ @assets_dir = File.join(File.dirname(__FILE__), "..", "..", "assets")
20
+ @sse_clients = [] # [[slug, queue], ...]
21
+ @sse_mutex = Mutex.new
22
+ @write_jobs = {} # slug => { title:, status:, error: }
23
+ @write_mutex = Mutex.new
24
+
25
+ @books = {}
26
+ config_paths.each do |cp|
27
+ config = Config.new(cp)
28
+ slug = File.basename(config.base_dir)
29
+ abort "Error: duplicate book slug '#{slug}' — use distinct directory names" if @books.key?(slug)
30
+ @books[slug] = BookEntry.new(
31
+ slug: slug,
32
+ config: config,
33
+ config_path: File.expand_path(cp),
34
+ build_dir: config.output_path,
35
+ store: ReviewStore.new(config.base_dir),
36
+ claude: ClaudeRunner.new(config)
37
+ )
38
+ end
39
+
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
45
+ end
46
+
47
+ def start
48
+ require_relative "builder"
49
+ @books.each_value do |book|
50
+ puts "Building #{book.config.title}..."
51
+ Builder.new(book.config_path).build
52
+ end
53
+
54
+ server = WEBrick::HTTPServer.new(
55
+ Port: @port,
56
+ BindAddress: "127.0.0.1",
57
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
58
+ AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]],
59
+ RequestBodyMaxSize: 50 * 1024 * 1024
60
+ )
61
+
62
+ server.mount_proc("/_ligarb/") { |req, res| handle_api(req, res) }
63
+ server.mount_proc("/") { |req, res| handle_static(req, res) }
64
+
65
+ trap("INT") { Thread.new { close_sse_clients; server.shutdown } }
66
+ trap("TERM") { Thread.new { close_sse_clients; server.shutdown } }
67
+
68
+ @books.each_value { |book| start_build_watcher(book) }
69
+
70
+ if @multi
71
+ puts "Serving #{@books.size} books at http://localhost:#{@port}"
72
+ @books.each_value { |b| puts " /#{b.slug}/ — #{b.config.title}" }
73
+ else
74
+ book = @books.values.first
75
+ puts "Serving #{book.config.title} at http://localhost:#{@port}"
76
+ puts " Build directory: #{book.build_dir}"
77
+ end
78
+ puts " Press Ctrl+C to stop"
79
+
80
+ server.start
81
+ end
82
+
83
+ private
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
+
106
+ # ── SSE (Server-Sent Events) ──
107
+
108
+ def close_sse_clients
109
+ @sse_mutex.synchronize do
110
+ @sse_clients.each { |_, q| q.push(:close) rescue nil }
111
+ @sse_clients.clear
112
+ end
113
+ end
114
+
115
+ def sse_broadcast(event, data, slug: nil)
116
+ json = JSON.generate(data)
117
+ message = "event: #{event}\ndata: #{json}\n\n"
118
+ @sse_mutex.synchronize do
119
+ @sse_clients.reject! do |client_slug, queue|
120
+ next false if slug && client_slug != slug
121
+ begin
122
+ queue.push(message, true)
123
+ false
124
+ rescue ThreadError
125
+ true # queue full, drop client
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ def handle_sse(book_slug, _req, res)
132
+ res["Content-Type"] = "text/event-stream"
133
+ res["Cache-Control"] = "no-cache"
134
+ res["Connection"] = "keep-alive"
135
+ res["X-Accel-Buffering"] = "no"
136
+ res.chunked = true
137
+
138
+ queue = SizedQueue.new(50)
139
+ @sse_mutex.synchronize { @sse_clients << [book_slug, queue] }
140
+
141
+ res.body = proc { |socket|
142
+ begin
143
+ socket.write("retry: 3000\n\n")
144
+ loop do
145
+ msg = queue.pop
146
+ break if msg == :close
147
+ socket.write(msg)
148
+ end
149
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
150
+ # Client disconnected
151
+ ensure
152
+ @sse_mutex.synchronize { @sse_clients.delete_if { |_, q| q == queue } }
153
+ end
154
+ }
155
+ end
156
+
157
+ # ── Build watcher ──
158
+
159
+ def start_build_watcher(book)
160
+ html_path = File.join(book.build_dir, "index.html")
161
+
162
+ if use_inotify?
163
+ log "Watching #{html_path} with inotify"
164
+ Thread.new do
165
+ Inotify.watch_file(html_path) do
166
+ log "Build updated: #{book.slug} (inotify)"
167
+ sse_broadcast("build_updated", { mtime: File.mtime(html_path).to_i }, slug: book.slug)
168
+ end
169
+ rescue => e
170
+ log "inotify watcher error: #{e.message}, falling back to polling"
171
+ start_mtime_poller(book)
172
+ end
173
+ else
174
+ start_mtime_poller(book)
175
+ end
176
+ end
177
+
178
+ def start_mtime_poller(book)
179
+ html_path = File.join(book.build_dir, "index.html")
180
+ log "Watching #{html_path} with polling"
181
+ Thread.new do
182
+ last_mtime = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
183
+ loop do
184
+ sleep 2
185
+ current = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
186
+ if current > last_mtime
187
+ last_mtime = current
188
+ sse_broadcast("build_updated", { mtime: current }, slug: book.slug)
189
+ end
190
+ rescue => e
191
+ log "mtime watcher error: #{e.message}"
192
+ end
193
+ end
194
+ end
195
+
196
+ def use_inotify?
197
+ return @use_inotify if defined?(@use_inotify)
198
+ @use_inotify = begin
199
+ require_relative "inotify"
200
+ true
201
+ rescue Fiddle::DLError, LoadError
202
+ false
203
+ end
204
+ end
205
+
206
+ # ── Static file serving ──
207
+
208
+ def handle_static(req, res)
209
+ path = req.path
210
+
211
+ unless @multi
212
+ # Single-book mode (original behavior)
213
+ if path == "/" || path == "/index.html"
214
+ serve_injected_html(@books.values.first, res)
215
+ return
216
+ end
217
+ serve_static_file(@books.values.first.build_dir, path, res)
218
+ return
219
+ end
220
+
221
+ # Multi-book mode
222
+ if path == "/" || path == "/index.html"
223
+ serve_index_page(res)
224
+ return
225
+ end
226
+
227
+ # /<slug>/...
228
+ if path =~ %r{^/([^/]+)(/.*)?$}
229
+ book = @books[$1]
230
+ if book
231
+ sub_path = $2 || "/"
232
+ if sub_path == "/" || sub_path == "/index.html"
233
+ serve_injected_html(book, res)
234
+ return
235
+ end
236
+ serve_static_file(book.build_dir, sub_path, res)
237
+ return
238
+ end
239
+ end
240
+
241
+ res.status = 404
242
+ res.body = "Not Found"
243
+ end
244
+
245
+ def serve_static_file(build_dir, path, res)
246
+ file_path = File.join(build_dir, path)
247
+ file_path = File.realpath(file_path) rescue nil
248
+
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)
251
+ res.body = File.binread(file_path)
252
+ res["Content-Type"] = mime_type(file_path)
253
+ else
254
+ res.status = 404
255
+ res.body = "Not Found"
256
+ end
257
+ end
258
+
259
+ def serve_injected_html(book, res)
260
+ html_path = File.join(book.build_dir, "index.html")
261
+ html = File.read(html_path)
262
+
263
+ css_tag = %(<link rel="stylesheet" href="/_ligarb/assets/review.css">)
264
+ html.sub!("</head>", "#{css_tag}\n</head>")
265
+
266
+ api_base = @multi ? "/_ligarb/#{book.slug}" : "/_ligarb"
267
+ page_base = @multi ? "/#{book.slug}/" : "/"
268
+ config_tag = %(<script>window._ligarbAPI='#{escape_js_string(api_base)}';window._ligarbBase='#{escape_js_string(page_base)}';</script>)
269
+
270
+ js_tags = [config_tag] + %w[serve.js review.js].map { |f|
271
+ %(<script src="/_ligarb/assets/#{f}"></script>)
272
+ }
273
+ html.sub!("</body>", "#{js_tags.join("\n")}\n</body>")
274
+
275
+ res.body = html
276
+ res["Content-Type"] = "text/html; charset=utf-8"
277
+ end
278
+
279
+ def serve_index_page(res)
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
283
+ {
284
+ slug: book.slug,
285
+ title: book.config.title,
286
+ author: book.config.author.to_s,
287
+ updated_at: mtime,
288
+ toc: build_toc(book)
289
+ }
290
+ }
291
+ books_json = JSON.generate(books_data)
292
+ write_jobs_json = JSON.generate(write_jobs_data)
293
+
294
+ html = <<~'HTML'
295
+ <!DOCTYPE html>
296
+ <html lang="en">
297
+ <head>
298
+ <meta charset="utf-8">
299
+ <meta name="viewport" content="width=device-width, initial-scale=1">
300
+ <title>ligarb librarium</title>
301
+ <style>
302
+ * { margin: 0; padding: 0; box-sizing: border-box; }
303
+ body { font-family: system-ui, -apple-system, sans-serif; color: #333; height: 100vh; display: flex; flex-direction: column; }
304
+ .idx-header { padding: 14px 20px; border-bottom: 1px solid #e0e0e0; font-size: 20px; font-weight: 600; flex-shrink: 0; }
305
+ .idx-container { display: flex; flex: 1; overflow: hidden; }
306
+ .idx-books { width: 280px; border-right: 1px solid #e0e0e0; overflow-y: auto; padding: 8px; flex-shrink: 0; display: flex; flex-direction: column; }
307
+ .idx-books-list { flex: 1; }
308
+ .idx-book { padding: 12px 16px; border-radius: 6px; cursor: pointer; transition: background 0.15s; }
309
+ .idx-book:hover { background: #f6f8fa; }
310
+ .idx-book.active { background: #eff6ff; border-left: 3px solid #2563eb; padding-left: 13px; }
311
+ .idx-book-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
312
+ .idx-book-author { font-size: 13px; color: #666; margin-top: 2px; }
313
+ .idx-book-updated { font-size: 12px; color: #999; margin-top: 2px; }
314
+ .idx-badge { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
315
+ .idx-badge-writing { background: #fff3e0; color: #e65100; animation: idx-pulse 1.5s ease-in-out infinite; }
316
+ .idx-badge-done { background: #e8f5e9; color: #2e7d32; }
317
+ .idx-badge-error { background: #ffebee; color: #c62828; }
318
+ @keyframes idx-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
319
+ .idx-write-btn { display: block; margin: 8px; padding: 10px; border: 2px dashed #ccc; border-radius: 6px; background: none; color: #666; font-size: 14px; cursor: pointer; transition: all 0.15s; text-align: center; flex-shrink: 0; }
320
+ .idx-write-btn:hover { border-color: #2563eb; color: #2563eb; background: #f0f6ff; }
321
+ .idx-toc { flex: 1; overflow-y: auto; padding: 20px 28px; }
322
+ .idx-toc-empty { color: #999; font-size: 15px; padding: 60px 0; text-align: center; }
323
+ .idx-toc-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
324
+ .idx-toc-title a { color: #2563eb; text-decoration: none; }
325
+ .idx-toc-title a:hover { text-decoration: underline; }
326
+ .idx-toc-part { font-size: 12px; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 18px; margin-bottom: 6px; padding-left: 4px; }
327
+ .idx-toc-ch { display: block; padding: 5px 12px; border-radius: 4px; text-decoration: none; color: #333; font-size: 14px; line-height: 1.5; transition: background 0.15s; }
328
+ .idx-toc-ch:hover { background: #f6f8fa; }
329
+ .idx-toc-cover { color: #666; font-style: italic; }
330
+ .idx-form { max-width: 480px; }
331
+ .idx-form h2 { font-size: 18px; margin-bottom: 16px; }
332
+ .idx-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #555; }
333
+ .idx-form input, .idx-form textarea { width: 100%; padding: 8px 10px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 12px; }
334
+ .idx-form input:focus, .idx-form textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
335
+ .idx-form textarea { resize: vertical; min-height: 80px; }
336
+ .idx-form-actions { display: flex; gap: 8px; margin-top: 4px; }
337
+ .idx-form-submit { padding: 8px 20px; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 14px; font-weight: 600; cursor: pointer; }
338
+ .idx-form-submit:hover { background: #1d4ed8; }
339
+ .idx-form-submit:disabled { background: #93c5fd; cursor: not-allowed; }
340
+ .idx-form-cancel { padding: 8px 16px; background: none; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; cursor: pointer; color: #666; }
341
+ .idx-form-cancel:hover { background: #f6f8fa; }
342
+ .idx-form-error { color: #c62828; font-size: 13px; margin-bottom: 8px; }
343
+ .idx-dropzone { border: 2px dashed #d0d0d0; border-radius: 6px; padding: 16px; text-align: center; margin-bottom: 12px; transition: border-color 0.15s, background 0.15s; }
344
+ .idx-dropzone-active { border-color: #2563eb; background: #f0f6ff; }
345
+ .idx-dropzone-text { font-size: 13px; color: #888; }
346
+ .idx-dropzone-text a { color: #2563eb; text-decoration: none; cursor: pointer; }
347
+ .idx-dropzone-text a:hover { text-decoration: underline; }
348
+ .idx-file-names { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
349
+ .idx-file-tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: #eff6ff; border-radius: 4px; font-size: 12px; color: #333; }
350
+ .idx-file-tag a { color: #999; text-decoration: none; font-size: 14px; }
351
+ .idx-file-tag a:hover { color: #c62828; }
352
+ </style>
353
+ </head>
354
+ <body>
355
+ <div class="idx-header">Machinascripta</div>
356
+ <div class="idx-container">
357
+ <div class="idx-books">
358
+ <div class="idx-books-list" id="idx-books"></div>
359
+ <button class="idx-write-btn" id="idx-write-btn">+ Write a new book</button>
360
+ </div>
361
+ <div class="idx-toc" id="idx-toc">
362
+ <div class="idx-toc-empty">Select a book to view its table of contents</div>
363
+ </div>
364
+ </div>
365
+ <script>
366
+ var books = __BOOKS_JSON__;
367
+ var writeJobs = __WRITE_JOBS_JSON__;
368
+ var listEl = document.getElementById('idx-books');
369
+ var tocEl = document.getElementById('idx-toc');
370
+ var activeEl = null;
371
+ var jobElements = {};
372
+
373
+ function renderBooks() {
374
+ listEl.innerHTML = '';
375
+ books.forEach(function(book) {
376
+ var el = createBookEl(book);
377
+ listEl.appendChild(el);
378
+ });
379
+ writeJobs.forEach(function(job) {
380
+ if (job.status !== 'done' && !books.find(function(b) { return b.slug === job.slug; })) {
381
+ var el = createJobEl(job);
382
+ jobElements[job.slug] = el;
383
+ listEl.appendChild(el);
384
+ }
385
+ });
386
+ if (books.length === 1 && writeJobs.length === 0) listEl.children[0].click();
387
+ }
388
+
389
+ function createBookEl(book) {
390
+ var el = document.createElement('div');
391
+ el.className = 'idx-book';
392
+ el.dataset.slug = book.slug;
393
+ var badge = '';
394
+ var job = writeJobs.find(function(j) { return j.slug === book.slug && j.status === 'done'; });
395
+ if (job) badge = ' <span class="idx-badge idx-badge-done">New!</span>';
396
+ el.innerHTML = '<div class="idx-book-title">' + esc(book.title) + badge + '</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>' : '');
399
+ el.addEventListener('click', function() {
400
+ if (activeEl) activeEl.classList.remove('active');
401
+ el.classList.add('active');
402
+ activeEl = el;
403
+ showToc(book);
404
+ });
405
+ return el;
406
+ }
407
+
408
+ function createJobEl(job) {
409
+ var el = document.createElement('div');
410
+ el.className = 'idx-book';
411
+ el.dataset.slug = job.slug;
412
+ var badgeClass = job.status === 'writing' ? 'idx-badge-writing' : (job.status === 'error' ? 'idx-badge-error' : 'idx-badge-done');
413
+ var badgeText = job.status === 'writing' ? 'Writing\u2026' : (job.status === 'error' ? 'Error' : 'New!');
414
+ el.innerHTML = '<div class="idx-book-title">' + esc(job.title) + ' <span class="idx-badge ' + badgeClass + '">' + badgeText + '</span></div>';
415
+ if (job.status === 'error') {
416
+ el.addEventListener('click', function() {
417
+ if (activeEl) activeEl.classList.remove('active');
418
+ el.classList.add('active');
419
+ activeEl = el;
420
+ tocEl.innerHTML = '<div class="idx-form"><h2>' + esc(job.title) + '</h2><div class="idx-form-error">Error: ' + esc(job.error || 'Unknown error') + '</div></div>';
421
+ });
422
+ }
423
+ return el;
424
+ }
425
+
426
+ renderBooks();
427
+
428
+ // Write form
429
+ document.getElementById('idx-write-btn').addEventListener('click', function() {
430
+ if (activeEl) activeEl.classList.remove('active');
431
+ activeEl = null;
432
+ showWriteForm();
433
+ });
434
+
435
+ function showWriteForm() {
436
+ tocEl.innerHTML =
437
+ '<div class="idx-form">' +
438
+ '<h2>Write a new book</h2>' +
439
+ '<div class="idx-form-error" id="idx-form-err" style="display:none"></div>' +
440
+ '<label for="wf-dir">Directory *</label>' +
441
+ '<input id="wf-dir" placeholder="ruby-intro">' +
442
+ '<label for="wf-title">Title *</label>' +
443
+ '<input id="wf-title" placeholder="Ruby入門">' +
444
+ '<label for="wf-lang">Language</label>' +
445
+ '<input id="wf-lang" value="ja">' +
446
+ '<label for="wf-audience">Audience</label>' +
447
+ '<input id="wf-audience" placeholder="初心者">' +
448
+ '<label for="wf-notes">Notes</label>' +
449
+ '<textarea id="wf-notes" placeholder="5章くらいで"></textarea>' +
450
+ '<label>Reference Files</label>' +
451
+ '<div class="idx-dropzone" id="wf-dropzone">' +
452
+ '<input type="file" id="wf-files" multiple style="display:none">' +
453
+ '<span class="idx-dropzone-text">Drop files here or <a href="#" id="wf-browse">browse</a></span>' +
454
+ '<div class="idx-file-names" id="wf-file-names"></div>' +
455
+ '</div>' +
456
+ '<div class="idx-form-actions">' +
457
+ '<button class="idx-form-submit" id="wf-submit">Start Writing</button>' +
458
+ '<button class="idx-form-cancel" id="wf-cancel">Cancel</button>' +
459
+ '</div></div>';
460
+
461
+ document.getElementById('wf-cancel').addEventListener('click', function() {
462
+ tocEl.innerHTML = '<div class="idx-toc-empty">Select a book to view its table of contents</div>';
463
+ });
464
+
465
+ var wfPendingFiles = [];
466
+ var wfDropzone = document.getElementById('wf-dropzone');
467
+ var wfFileInput = document.getElementById('wf-files');
468
+ var wfFileNames = document.getElementById('wf-file-names');
469
+
470
+ document.getElementById('wf-browse').addEventListener('click', function(e) {
471
+ e.preventDefault();
472
+ wfFileInput.click();
473
+ });
474
+
475
+ wfFileInput.addEventListener('change', function() {
476
+ addPendingFiles(wfFileInput.files, wfPendingFiles, wfFileNames);
477
+ });
478
+
479
+ wfDropzone.addEventListener('dragover', function(e) {
480
+ e.preventDefault();
481
+ wfDropzone.classList.add('idx-dropzone-active');
482
+ });
483
+ wfDropzone.addEventListener('dragleave', function() {
484
+ wfDropzone.classList.remove('idx-dropzone-active');
485
+ });
486
+ wfDropzone.addEventListener('drop', function(e) {
487
+ e.preventDefault();
488
+ wfDropzone.classList.remove('idx-dropzone-active');
489
+ addPendingFiles(e.dataTransfer.files, wfPendingFiles, wfFileNames);
490
+ });
491
+
492
+ document.getElementById('wf-submit').addEventListener('click', function() {
493
+ var dir = document.getElementById('wf-dir').value.trim();
494
+ var title = document.getElementById('wf-title').value.trim();
495
+ var errEl = document.getElementById('idx-form-err');
496
+ errEl.style.display = 'none';
497
+
498
+ if (!dir || !title) {
499
+ errEl.textContent = 'Directory and Title are required.';
500
+ errEl.style.display = 'block';
501
+ return;
502
+ }
503
+ if (/[\/\\]/.test(dir) || dir.startsWith('.')) {
504
+ errEl.textContent = 'Invalid directory name.';
505
+ errEl.style.display = 'block';
506
+ return;
507
+ }
508
+
509
+ var btn = document.getElementById('wf-submit');
510
+ btn.disabled = true;
511
+ btn.textContent = 'Starting\u2026';
512
+
513
+ readFilesAsBase64(wfPendingFiles).then(function(filesData) {
514
+ var payload = {
515
+ directory: dir,
516
+ title: title,
517
+ language: document.getElementById('wf-lang').value.trim() || 'ja',
518
+ audience: document.getElementById('wf-audience').value.trim(),
519
+ notes: document.getElementById('wf-notes').value.trim()
520
+ };
521
+ if (filesData.length > 0) payload.files = filesData;
522
+
523
+ return fetch('/_ligarb/write', {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/json' },
526
+ body: JSON.stringify(payload)
527
+ });
528
+ }).then(function(r) { return r.json().then(function(d) { return { ok: r.ok, data: d }; }); })
529
+ .then(function(result) {
530
+ if (!result.ok) {
531
+ errEl.textContent = result.data.error || 'Request failed';
532
+ errEl.style.display = 'block';
533
+ btn.disabled = false;
534
+ btn.textContent = 'Start Writing';
535
+ return;
536
+ }
537
+ var job = { slug: result.data.slug, title: result.data.title, status: 'writing' };
538
+ writeJobs.push(job);
539
+ var el = createJobEl(job);
540
+ jobElements[job.slug] = el;
541
+ listEl.appendChild(el);
542
+ tocEl.innerHTML = '<div class="idx-toc-empty">Writing "' + esc(title) + '"\u2026 This may take a few minutes.</div>';
543
+ })
544
+ .catch(function(e) {
545
+ errEl.textContent = 'Network error: ' + e.message;
546
+ errEl.style.display = 'block';
547
+ btn.disabled = false;
548
+ btn.textContent = 'Start Writing';
549
+ });
550
+ });
551
+ }
552
+
553
+ function showToc(book) {
554
+ var h = '<div class="idx-toc-title"><a href="/' + book.slug + '/" target="_blank">' + esc(book.title) + ' &rarr;</a></div>';
555
+ book.toc.forEach(function(e) {
556
+ if (e.type === 'part') {
557
+ h += '<div class="idx-toc-part">' + esc(e.title) + '</div>';
558
+ } else if (e.type === 'appendix_header') {
559
+ h += '<div class="idx-toc-part">' + esc(e.label) + '</div>';
560
+ } else if (e.type === 'cover') {
561
+ h += '<a class="idx-toc-ch idx-toc-cover" href="/' + book.slug + '/#' + e.slug + '" target="_blank">' + esc(e.title) + '</a>';
562
+ } else {
563
+ h += '<a class="idx-toc-ch" href="/' + book.slug + '/#' + e.slug + '" target="_blank">' + esc(e.title) + '</a>';
564
+ }
565
+ });
566
+ tocEl.innerHTML = h;
567
+ }
568
+
569
+ function esc(s) {
570
+ var d = document.createElement('div');
571
+ d.textContent = s || '';
572
+ return d.innerHTML;
573
+ }
574
+
575
+ function addPendingFiles(fileList, pending, namesEl) {
576
+ for (var i = 0; i < fileList.length; i++) {
577
+ pending.push(fileList[i]);
578
+ }
579
+ renderFileNames(pending, namesEl);
580
+ }
581
+
582
+ function renderFileNames(pending, namesEl) {
583
+ namesEl.innerHTML = '';
584
+ pending.forEach(function(f, i) {
585
+ var span = document.createElement('span');
586
+ span.className = 'idx-file-tag';
587
+ span.innerHTML = esc(f.name) + ' <a href="#" data-idx="' + i + '">&times;</a>';
588
+ span.querySelector('a').addEventListener('click', function(e) {
589
+ e.preventDefault();
590
+ pending.splice(parseInt(this.dataset.idx), 1);
591
+ renderFileNames(pending, namesEl);
592
+ });
593
+ namesEl.appendChild(span);
594
+ });
595
+ }
596
+
597
+ function readFilesAsBase64(files) {
598
+ if (!files || files.length === 0) return Promise.resolve([]);
599
+ var promises = files.map(function(f) {
600
+ return new Promise(function(resolve) {
601
+ var reader = new FileReader();
602
+ reader.onload = function() {
603
+ var base64 = reader.result.split(',')[1] || '';
604
+ resolve({ name: f.name, data: base64 });
605
+ };
606
+ reader.readAsDataURL(f);
607
+ });
608
+ });
609
+ return Promise.all(promises);
610
+ }
611
+
612
+ // SSE for write updates
613
+ var evtSource = new EventSource('/_ligarb/events');
614
+ evtSource.addEventListener('write_updated', function(e) {
615
+ var jobs = JSON.parse(e.data);
616
+ writeJobs = jobs;
617
+
618
+ // Reload page to get full book data when a job completes
619
+ var hasDone = jobs.find(function(j) { return j.status === 'done'; });
620
+ if (hasDone) {
621
+ // Fetch fresh books data to get TOC etc.
622
+ location.reload();
623
+ return;
624
+ }
625
+
626
+ // Update existing job elements
627
+ jobs.forEach(function(job) {
628
+ var existing = jobElements[job.slug];
629
+ if (existing) {
630
+ var badgeClass = job.status === 'writing' ? 'idx-badge-writing' : (job.status === 'error' ? 'idx-badge-error' : 'idx-badge-done');
631
+ var badgeText = job.status === 'writing' ? 'Writing\u2026' : (job.status === 'error' ? 'Error' : 'New!');
632
+ existing.querySelector('.idx-book-title').innerHTML = esc(job.title) + ' <span class="idx-badge ' + badgeClass + '">' + badgeText + '</span>';
633
+ }
634
+ });
635
+ });
636
+ </script>
637
+ </body>
638
+ </html>
639
+ HTML
640
+
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("</", '<\/'))
644
+ res.body = html
645
+ res["Content-Type"] = "text/html; charset=utf-8"
646
+ end
647
+
648
+ def build_toc(book)
649
+ toc = []
650
+ chapter_num = 0
651
+ appendix_idx = 0
652
+
653
+ book.config.structure.each do |entry|
654
+ case entry.type
655
+ when :cover
656
+ title = first_heading(entry.path) || "Cover"
657
+ slug = file_slug(entry.path)
658
+ toc << { type: "cover", title: title, slug: slug }
659
+ when :chapter
660
+ chapter_num += 1
661
+ title = first_heading(entry.path) || File.basename(entry.path, ".md")
662
+ slug = file_slug(entry.path)
663
+ prefix = book.config.chapter_numbers ? "#{chapter_num}. " : ""
664
+ toc << { type: "chapter", title: "#{prefix}#{title}", slug: slug }
665
+ when :part
666
+ part_title = first_heading(entry.path) || "Part"
667
+ toc << { type: "part", title: part_title }
668
+ (entry.children || []).each do |ch|
669
+ chapter_num += 1
670
+ title = first_heading(ch.path) || File.basename(ch.path, ".md")
671
+ slug = file_slug(ch.path)
672
+ prefix = book.config.chapter_numbers ? "#{chapter_num}. " : ""
673
+ toc << { type: "chapter", title: "#{prefix}#{title}", slug: slug }
674
+ end
675
+ when :appendix_group
676
+ toc << { type: "appendix_header", label: book.config.appendix_label }
677
+ (entry.children || []).each_with_index do |ch, i|
678
+ title = first_heading(ch.path) || File.basename(ch.path, ".md")
679
+ slug = file_slug(ch.path)
680
+ letter = ("A".ord + appendix_idx + i).chr
681
+ toc << { type: "chapter", title: "#{book.config.appendix_label} #{letter}. #{title}", slug: slug }
682
+ end
683
+ appendix_idx += (entry.children || []).size
684
+ end
685
+ end
686
+
687
+ toc
688
+ end
689
+
690
+ def first_heading(path)
691
+ return nil unless path && File.exist?(path)
692
+ File.foreach(path) do |line|
693
+ return $1.strip if line =~ /\A#\s+(.+)/
694
+ end
695
+ nil
696
+ end
697
+
698
+ def file_slug(path)
699
+ File.basename(path, ".md").gsub(/[^a-zA-Z0-9_-]/, "-")
700
+ end
701
+
702
+ # ── API routing ──
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
+
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
+
730
+ method = req.request_method
731
+ path = req.path.sub(%r{^/_ligarb}, "")
732
+
733
+ # Shared assets (both modes)
734
+ if method == "GET" && path =~ %r{^/assets/(.+)$}
735
+ serve_asset($1, res)
736
+ return
737
+ end
738
+
739
+ # Global write API (multi-book mode)
740
+ if @multi
741
+ if method == "GET" && path == "/events"
742
+ handle_sse(nil, req, res)
743
+ return
744
+ end
745
+ if method == "POST" && path == "/write"
746
+ res["Content-Type"] = "application/json; charset=utf-8"
747
+ begin
748
+ api_start_write(req, res)
749
+ rescue => e
750
+ res.status = 500
751
+ res.body = JSON.generate({ error: e.message })
752
+ end
753
+ return
754
+ end
755
+ if method == "GET" && path == "/write/status"
756
+ res["Content-Type"] = "application/json; charset=utf-8"
757
+ api_write_status(res)
758
+ return
759
+ end
760
+ end
761
+
762
+ # Resolve book
763
+ if @multi
764
+ unless path =~ %r{^/([^/]+)(/.*)?$}
765
+ res["Content-Type"] = "application/json; charset=utf-8"
766
+ not_found(res)
767
+ return
768
+ end
769
+ book = @books[$1]
770
+ unless book
771
+ res["Content-Type"] = "application/json; charset=utf-8"
772
+ not_found(res)
773
+ return
774
+ end
775
+ api_path = $2 || "/"
776
+ else
777
+ book = @books.values.first
778
+ api_path = path
779
+ end
780
+
781
+ # SSE endpoint
782
+ if method == "GET" && api_path == "/events"
783
+ handle_sse(book.slug, req, res)
784
+ return
785
+ end
786
+
787
+ res["Content-Type"] = "application/json; charset=utf-8"
788
+
789
+ begin
790
+ if method == "GET" && api_path == "/status"
791
+ api_status(book, res)
792
+ elsif method == "GET" && api_path == "/reviews"
793
+ api_list_reviews(book, res)
794
+ elsif method == "POST" && api_path == "/reviews"
795
+ api_create_review(book, req, res)
796
+ elsif method == "GET" && api_path =~ %r{^/reviews/([0-9a-f-]+)$}
797
+ api_get_review(book, $1, res)
798
+ elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/messages$}
799
+ api_add_message(book, $1, req, res)
800
+ elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/approve$}
801
+ api_approve(book, $1, res)
802
+ elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/close$}
803
+ api_delete_review(book, $1, res)
804
+ else
805
+ not_found(res)
806
+ end
807
+ rescue => e
808
+ res.status = 500
809
+ res.body = JSON.generate({ error: e.message })
810
+ end
811
+ end
812
+
813
+ # ── API handlers ──
814
+
815
+ def api_status(book, res)
816
+ html_path = File.join(book.build_dir, "index.html")
817
+ mtime = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
818
+ res.body = JSON.generate({ mtime: mtime })
819
+ end
820
+
821
+ def api_list_reviews(book, res)
822
+ res.body = JSON.generate(book.store.list)
823
+ end
824
+
825
+ def api_get_review(book, id, res)
826
+ review = book.store.get(id)
827
+ if review
828
+ res.body = JSON.generate(review)
829
+ else
830
+ not_found(res)
831
+ end
832
+ end
833
+
834
+ def api_create_review(book, req, res)
835
+ body = parse_body(req)
836
+
837
+ context = body["context"] || {}
838
+ message = body["message"]
839
+
840
+ unless message && !message.strip.empty?
841
+ res.status = 400
842
+ res.body = JSON.generate({ error: "message is required" })
843
+ return
844
+ end
845
+
846
+ context["source_file"] = resolve_source_file(book.config, context["chapter_slug"])
847
+
848
+ saved = save_uploaded_files(body["files"], book.config.base_dir)
849
+ context["uploaded_files"] = saved unless saved.empty?
850
+
851
+ review = book.store.create(context: context, message: message)
852
+ start_claude_review(book, review["id"])
853
+
854
+ res.status = 201
855
+ res.body = JSON.generate(review)
856
+ end
857
+
858
+ def api_add_message(book, id, req, res)
859
+ review = book.store.get(id)
860
+ unless review
861
+ not_found(res)
862
+ return
863
+ end
864
+
865
+ body = parse_body(req)
866
+ message = body["message"]
867
+
868
+ unless message && !message.strip.empty?
869
+ res.status = 400
870
+ res.body = JSON.generate({ error: "message is required" })
871
+ return
872
+ end
873
+
874
+ saved = save_uploaded_files(body["files"], book.config.base_dir)
875
+ book.store.update_context_files(id, saved) unless saved.empty?
876
+
877
+ book.store.add_message(id, role: "user", content: message)
878
+ start_claude_review(book, id)
879
+
880
+ review = book.store.get(id)
881
+ res.body = JSON.generate(review)
882
+ end
883
+
884
+ def api_approve(book, id, res)
885
+ review = book.store.get(id)
886
+ unless review
887
+ not_found(res)
888
+ return
889
+ end
890
+
891
+ log "Approve: applying patches for review #{id}"
892
+
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"]
937
+ log "Approve: error: #{result["error"]}"
938
+ book.store.add_message(id, role: "assistant", content: "Error: #{result["error"]}")
939
+ book.store.update_status(id, "open")
940
+ else
941
+ log "Approve: #{result["text"]}"
942
+ book.store.add_message(id, role: "assistant", content: result["text"])
943
+ book.store.update_status(id, "applied")
944
+ end
945
+
946
+ sse_broadcast("review_updated", { id: id }, slug: book.slug)
947
+ review = book.store.get(id)
948
+ res.body = JSON.generate(review)
949
+ end
950
+
951
+ def api_delete_review(book, id, res)
952
+ review = book.store.get(id)
953
+ unless review
954
+ not_found(res)
955
+ return
956
+ end
957
+
958
+ book.store.update_status(id, "closed")
959
+ sse_broadcast("review_updated", { id: id }, slug: book.slug)
960
+
961
+ review = book.store.get(id)
962
+ res.body = JSON.generate(review)
963
+ end
964
+
965
+ def serve_asset(filename, res)
966
+ unless INJECTED_ASSETS.include?(filename)
967
+ not_found(res)
968
+ return
969
+ end
970
+
971
+ path = File.join(@assets_dir, filename)
972
+ unless File.exist?(path)
973
+ not_found(res)
974
+ return
975
+ end
976
+
977
+ res.body = File.read(path)
978
+ res["Content-Type"] = mime_type(path)
979
+ end
980
+
981
+ def start_claude_review(book, review_id)
982
+ Thread.new do
983
+ log "Review: starting Claude review for #{review_id}"
984
+ begin
985
+ review = book.store.get(review_id)
986
+ next unless review
987
+
988
+ case book.claude.installed?
989
+ when :not_found
990
+ book.store.add_message(review_id, role: "assistant",
991
+ content: "Error: 'claude' command not found. Install Claude Code to enable AI reviews.")
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
997
+ end
998
+
999
+ prompt = book.claude.review_prompt(review)
1000
+ result = book.claude.run(prompt)
1001
+
1002
+ if result["error"]
1003
+ log "Review: Claude error: #{result["error"]}"
1004
+ book.store.add_message(review_id, role: "assistant", content: "Error: #{result["error"]}")
1005
+ else
1006
+ log "Review: Claude responded"
1007
+ book.store.add_message(review_id, role: "assistant", content: result["text"])
1008
+ end
1009
+ rescue => e
1010
+ log "Review: exception: #{e.message}"
1011
+ book.store.add_message(review_id, role: "assistant", content: "Error: #{e.message}")
1012
+ ensure
1013
+ sse_broadcast("review_updated", { id: review_id }, slug: book.slug)
1014
+ end
1015
+ end
1016
+ end
1017
+
1018
+ # ── Write API ──
1019
+
1020
+ def api_start_write(req, res)
1021
+ body = parse_body(req)
1022
+ directory = body["directory"].to_s.strip
1023
+ title = body["title"].to_s.strip
1024
+
1025
+ if directory.empty? || title.empty?
1026
+ res.status = 400
1027
+ res.body = JSON.generate({ error: "directory and title are required" })
1028
+ return
1029
+ end
1030
+
1031
+ if directory.include?("/") || directory.include?("\\") || directory.start_with?(".")
1032
+ res.status = 400
1033
+ res.body = JSON.generate({ error: "invalid directory name" })
1034
+ return
1035
+ end
1036
+
1037
+ if @books.key?(directory)
1038
+ res.status = 409
1039
+ res.body = JSON.generate({ error: "a book with slug '#{directory}' already exists" })
1040
+ return
1041
+ end
1042
+
1043
+ target_dir = File.join(Dir.pwd, directory)
1044
+ if Dir.exist?(target_dir)
1045
+ res.status = 409
1046
+ res.body = JSON.generate({ error: "directory '#{directory}' already exists" })
1047
+ return
1048
+ end
1049
+
1050
+ @write_mutex.synchronize do
1051
+ if @write_jobs.key?(directory)
1052
+ res.status = 409
1053
+ res.body = JSON.generate({ error: "a write job for '#{directory}' is already running" })
1054
+ return
1055
+ end
1056
+ @write_jobs[directory] = { title: title, status: "writing", error: nil }
1057
+ end
1058
+
1059
+ # Write brief.yml
1060
+ FileUtils.mkdir_p(target_dir)
1061
+ brief_path = File.join(target_dir, "brief.yml")
1062
+ brief_data = { "title" => title, "language" => body["language"] || "ja" }
1063
+ brief_data["audience"] = body["audience"] if body["audience"] && !body["audience"].to_s.strip.empty?
1064
+ brief_data["notes"] = body["notes"] if body["notes"] && !body["notes"].to_s.strip.empty?
1065
+
1066
+ saved = save_uploaded_files(body["files"], target_dir)
1067
+ if saved.any?
1068
+ brief_data["sources"] = saved.map { |f| { "path" => f["path"], "label" => f["label"] } }
1069
+ end
1070
+
1071
+ File.write(brief_path, YAML.dump(brief_data))
1072
+
1073
+ sse_broadcast("write_updated", write_jobs_data, slug: nil)
1074
+
1075
+ # Run write job via operation queue (async)
1076
+ enqueue_async do
1077
+ begin
1078
+ log "Write: starting for '#{directory}' (title: #{title})"
1079
+ require_relative "writer"
1080
+ writer = Writer.new(brief_path, no_build: true)
1081
+ writer.run
1082
+
1083
+ book_yml_path = File.join(target_dir, "book.yml")
1084
+ require_relative "builder"
1085
+ Builder.new(book_yml_path).build
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
+
1097
+ # Register the new book
1098
+ config = Config.new(book_yml_path)
1099
+ book = BookEntry.new(
1100
+ slug: directory,
1101
+ config: config,
1102
+ config_path: File.expand_path(book_yml_path),
1103
+ build_dir: config.output_path,
1104
+ store: ReviewStore.new(config.base_dir),
1105
+ claude: ClaudeRunner.new(config)
1106
+ )
1107
+ @books[directory] = book
1108
+ start_build_watcher(book)
1109
+
1110
+ @write_mutex.synchronize { @write_jobs[directory][:status] = "done" }
1111
+ log "Write: completed for '#{directory}'"
1112
+ rescue => e
1113
+ @write_mutex.synchronize do
1114
+ @write_jobs[directory][:status] = "error"
1115
+ @write_jobs[directory][:error] = e.message
1116
+ end
1117
+ log "Write: error for '#{directory}': #{e.message}"
1118
+ ensure
1119
+ sse_broadcast("write_updated", write_jobs_data, slug: nil)
1120
+ end
1121
+ end
1122
+
1123
+ res.status = 201
1124
+ res.body = JSON.generate({ slug: directory, title: title, status: "writing" })
1125
+ end
1126
+
1127
+ def api_write_status(res)
1128
+ res.body = JSON.generate(write_jobs_data)
1129
+ end
1130
+
1131
+ def write_jobs_data
1132
+ @write_mutex.synchronize do
1133
+ @write_jobs.map { |slug, job| { slug: slug, title: job[:title], status: job[:status], error: job[:error] } }
1134
+ end
1135
+ end
1136
+
1137
+ # ── Helpers ──
1138
+
1139
+ MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 # 10MB per file
1140
+
1141
+ def save_uploaded_files(files_array, base_dir)
1142
+ return [] unless files_array.is_a?(Array) && !files_array.empty?
1143
+
1144
+ upload_dir = File.join(base_dir, ".ligarb", "uploads")
1145
+ FileUtils.mkdir_p(upload_dir)
1146
+
1147
+ files_array.filter_map do |file|
1148
+ name = file["name"].to_s.strip
1149
+ data = file["data"].to_s
1150
+ next if name.empty? || data.empty?
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
+
1158
+ # Sanitize filename
1159
+ safe_name = File.basename(name).gsub(/[^a-zA-Z0-9._-]/, "_")
1160
+ short_id = SecureRandom.hex(4)
1161
+ filename = "#{short_id}-#{safe_name}"
1162
+ dest = File.join(upload_dir, filename)
1163
+
1164
+ File.binwrite(dest, decoded)
1165
+ { "path" => File.expand_path(dest), "label" => name }
1166
+ end
1167
+ end
1168
+
1169
+ def resolve_source_file(config, chapter_slug)
1170
+ return nil unless chapter_slug
1171
+
1172
+ config.all_file_paths.find { |path|
1173
+ slug = File.basename(path, ".md").gsub(/[^a-zA-Z0-9_-]/, "-")
1174
+ slug == chapter_slug
1175
+ }
1176
+ end
1177
+
1178
+ def parse_body(req)
1179
+ JSON.parse(req.body || "{}")
1180
+ rescue JSON::ParserError
1181
+ {}
1182
+ end
1183
+
1184
+ def not_found(res)
1185
+ res.status = 404
1186
+ res.body = JSON.generate({ error: "not found" })
1187
+ end
1188
+
1189
+ def log(msg)
1190
+ $stderr.puts "[ligarb #{Time.now.strftime('%H:%M:%S')}] #{msg}"
1191
+ end
1192
+
1193
+ def escape_html(str)
1194
+ str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
1195
+ end
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
+
1202
+ def mime_type(path)
1203
+ case File.extname(path).downcase
1204
+ when ".html" then "text/html; charset=utf-8"
1205
+ when ".css" then "text/css; charset=utf-8"
1206
+ when ".js" then "application/javascript; charset=utf-8"
1207
+ when ".json" then "application/json; charset=utf-8"
1208
+ when ".png" then "image/png"
1209
+ when ".jpg", ".jpeg" then "image/jpeg"
1210
+ when ".gif" then "image/gif"
1211
+ when ".svg" then "image/svg+xml"
1212
+ when ".woff" then "font/woff"
1213
+ when ".woff2" then "font/woff2"
1214
+ else "application/octet-stream"
1215
+ end
1216
+ end
1217
+ end
1218
+ end