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