ligarb 0.4.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.
- checksums.yaml +4 -4
- data/assets/review.css +665 -0
- data/assets/review.js +681 -0
- data/assets/serve.js +76 -0
- data/assets/style.css +67 -0
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +26 -2
- data/lib/ligarb/claude_runner.rb +185 -0
- data/lib/ligarb/cli.rb +106 -8
- data/lib/ligarb/config.rb +25 -1
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +112 -0
- data/lib/ligarb/server.rb +1091 -0
- data/lib/ligarb/template.rb +2 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +71 -18
- data/templates/book.html.erb +124 -12
- metadata +36 -1
data/lib/ligarb/cli.rb
CHANGED
|
@@ -17,15 +17,26 @@ module Ligarb
|
|
|
17
17
|
Builder.new(config_path).build
|
|
18
18
|
when "init"
|
|
19
19
|
Initializer.new(args.first).run
|
|
20
|
+
when "serve"
|
|
21
|
+
config_paths = args.reject { |a| a.start_with?("--") }
|
|
22
|
+
config_paths = ["book.yml"] if config_paths.empty?
|
|
23
|
+
port_idx = args.index("--port")
|
|
24
|
+
port = port_idx ? args[port_idx + 1].to_i : 3000
|
|
25
|
+
require_relative "server"
|
|
26
|
+
Server.new(config_paths, port: port).start
|
|
20
27
|
when "write"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
require_relative "writer"
|
|
29
|
+
begin
|
|
30
|
+
if args.delete("--init")
|
|
31
|
+
Writer.init_brief(args.first)
|
|
32
|
+
else
|
|
33
|
+
brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
|
|
34
|
+
no_build = args.include?("--no-build")
|
|
35
|
+
Writer.new(brief_path, no_build: no_build).run
|
|
36
|
+
end
|
|
37
|
+
rescue Writer::WriterError => e
|
|
38
|
+
$stderr.puts "Error: #{e.message}"
|
|
39
|
+
exit 1
|
|
29
40
|
end
|
|
30
41
|
when "--help", "-h", nil
|
|
31
42
|
print_usage
|
|
@@ -47,6 +58,7 @@ module Ligarb
|
|
|
47
58
|
Usage:
|
|
48
59
|
ligarb init [DIRECTORY] Create a new book project
|
|
49
60
|
ligarb build [CONFIG] Build the HTML book (default CONFIG: book.yml)
|
|
61
|
+
ligarb serve [CONFIG] Serve the book with live reload and review UI
|
|
50
62
|
ligarb write [BRIEF] Generate a book with AI from brief.yml
|
|
51
63
|
ligarb write --init [DIR] Create DIR/brief.yml template
|
|
52
64
|
ligarb help Show detailed specification (for AI integration)
|
|
@@ -109,6 +121,31 @@ module Ligarb
|
|
|
109
121
|
ligarb build [CONFIG] Build the HTML book.
|
|
110
122
|
CONFIG defaults to 'book.yml' in the current directory.
|
|
111
123
|
|
|
124
|
+
ligarb serve [CONFIG...]
|
|
125
|
+
Start a local web server with live reload and review UI.
|
|
126
|
+
CONFIG defaults to 'book.yml' in the current directory.
|
|
127
|
+
Multiple CONFIG paths can be given to serve multiple books.
|
|
128
|
+
Options:
|
|
129
|
+
--port PORT Server port (default: 3000)
|
|
130
|
+
Single book mode (1 CONFIG):
|
|
131
|
+
- Serves the built HTML book at http://localhost:PORT
|
|
132
|
+
Multi-book mode (2+ CONFIGs):
|
|
133
|
+
- Top page (/) shows a book index with links
|
|
134
|
+
- Each book is served at /<directory-name>/
|
|
135
|
+
- "Write a new book" button on the index page to generate
|
|
136
|
+
a new book via AI (posts a brief, runs Writer in background)
|
|
137
|
+
- Example: ligarb serve */book.yml
|
|
138
|
+
Features:
|
|
139
|
+
- Injects a reload button that pulses when build output changes
|
|
140
|
+
- Injects a review UI for commenting on book text
|
|
141
|
+
- Review comments are saved to .ligarb/reviews/*.json
|
|
142
|
+
(in each book's directory)
|
|
143
|
+
- If 'claude' CLI is installed, comments are sent to Claude
|
|
144
|
+
for review suggestions, and approved changes are applied
|
|
145
|
+
to the source Markdown files and the book is rebuilt
|
|
146
|
+
- Review patches can span multiple chapters and the
|
|
147
|
+
bibliography file (Claude reads book.yml to find all files)
|
|
148
|
+
|
|
112
149
|
ligarb help Show this detailed specification.
|
|
113
150
|
|
|
114
151
|
ligarb --help Show short usage summary.
|
|
@@ -344,6 +381,67 @@ module Ligarb
|
|
|
344
381
|
|
|
345
382
|
Clicking an index entry navigates to the exact location in the chapter.
|
|
346
383
|
|
|
384
|
+
== Bibliography ==
|
|
385
|
+
|
|
386
|
+
Cite references in the text using Markdown link syntax with #cite:
|
|
387
|
+
|
|
388
|
+
[Ruby](#cite:matz1995) Cite by key; rendered as Ruby[Matsumoto, 1995]
|
|
389
|
+
|
|
390
|
+
Define a bibliography data file in book.yml:
|
|
391
|
+
|
|
392
|
+
bibliography: references.yml # YAML format
|
|
393
|
+
bibliography: references.bib # BibTeX format
|
|
394
|
+
|
|
395
|
+
The format is auto-detected by file extension (.bib = BibTeX, otherwise YAML).
|
|
396
|
+
|
|
397
|
+
YAML format maps keys to reference data:
|
|
398
|
+
|
|
399
|
+
matz1995:
|
|
400
|
+
author: "Yukihiro Matsumoto"
|
|
401
|
+
title: "The Ruby Programming Language"
|
|
402
|
+
year: 1995
|
|
403
|
+
url: "https://www.ruby-lang.org"
|
|
404
|
+
publisher: "O'Reilly"
|
|
405
|
+
doi: "10.1234/example"
|
|
406
|
+
|
|
407
|
+
BibTeX format (.bib) is also supported:
|
|
408
|
+
|
|
409
|
+
@book{matz1995,
|
|
410
|
+
author = {Yukihiro Matsumoto},
|
|
411
|
+
title = {The Ruby Programming Language},
|
|
412
|
+
year = {1995},
|
|
413
|
+
publisher = {O'Reilly},
|
|
414
|
+
url = {https://www.ruby-lang.org}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
BibTeX notes:
|
|
418
|
+
- Entry types (@book, @article, etc.) are preserved for formatting
|
|
419
|
+
- Field values can use {braces} or "quotes"
|
|
420
|
+
- Nested braces are supported one level deep ({The {Ruby} Language})
|
|
421
|
+
- Lines starting with % are comments
|
|
422
|
+
|
|
423
|
+
Supported fields (YAML and BibTeX):
|
|
424
|
+
author, title, year, url, publisher, journal, booktitle, volume,
|
|
425
|
+
number, pages, edition, doi, editor, note.
|
|
426
|
+
|
|
427
|
+
The bibliography section formats entries by type:
|
|
428
|
+
- book: Author. Title. Edition. Publisher, Year.
|
|
429
|
+
- article: Author. "Title". Journal, Volume(Number), Pages, Year.
|
|
430
|
+
- inproceedings: Author. "Title". In Booktitle, Pages, Year.
|
|
431
|
+
- other/YAML: Author. Title. Publisher/Journal, Volume, Pages, Year.
|
|
432
|
+
|
|
433
|
+
If url is present, the title becomes a link. If doi is present, a DOI link
|
|
434
|
+
is appended.
|
|
435
|
+
|
|
436
|
+
The citation is rendered as a superscript [author, year] link that navigates
|
|
437
|
+
to the "Bibliography" section at the end of the book. Hovering the link shows
|
|
438
|
+
the full reference. The bibliography section lists all cited entries sorted by
|
|
439
|
+
author and year.
|
|
440
|
+
|
|
441
|
+
A warning is printed and the citation is rendered as [key?] (highlighted in
|
|
442
|
+
red) if a cite key is not found in the bibliography file.
|
|
443
|
+
If no bibliography file is configured, cite markers are left as-is.
|
|
444
|
+
|
|
347
445
|
== Custom CSS ==
|
|
348
446
|
|
|
349
447
|
Add a 'style' field to book.yml to inject custom CSS:
|
data/lib/ligarb/config.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Ligarb
|
|
|
14
14
|
|
|
15
15
|
attr_reader :title, :author, :language, :output_dir, :base_dir,
|
|
16
16
|
:chapter_numbers, :structure, :style, :repository,
|
|
17
|
-
:ai_generated, :footer
|
|
17
|
+
:ai_generated, :footer, :bibliography, :sources
|
|
18
18
|
|
|
19
19
|
def initialize(path)
|
|
20
20
|
@base_dir = File.dirname(File.expand_path(path))
|
|
@@ -31,6 +31,8 @@ module Ligarb
|
|
|
31
31
|
@repository = data.fetch("repository", nil)
|
|
32
32
|
@ai_generated = data.fetch("ai_generated", false)
|
|
33
33
|
@footer = data.fetch("footer", nil)
|
|
34
|
+
@bibliography = data.fetch("bibliography", nil)
|
|
35
|
+
@sources = parse_sources(data.fetch("sources", []))
|
|
34
36
|
@structure = parse_structure(data["chapters"])
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -42,6 +44,10 @@ module Ligarb
|
|
|
42
44
|
@style ? File.join(@base_dir, @style) : nil
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
def bibliography_path
|
|
48
|
+
@bibliography ? File.join(@base_dir, @bibliography) : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
45
51
|
def appendix_label
|
|
46
52
|
@language == "ja" ? "付録" : "Appendix"
|
|
47
53
|
end
|
|
@@ -69,6 +75,24 @@ module Ligarb
|
|
|
69
75
|
|
|
70
76
|
private
|
|
71
77
|
|
|
78
|
+
Source = Struct.new(:path, :label, keyword_init: true)
|
|
79
|
+
|
|
80
|
+
def parse_sources(entries)
|
|
81
|
+
return [] unless entries.is_a?(Array)
|
|
82
|
+
|
|
83
|
+
entries.map do |entry|
|
|
84
|
+
case entry
|
|
85
|
+
when String
|
|
86
|
+
Source.new(path: File.join(@base_dir, entry), label: File.basename(entry))
|
|
87
|
+
when Hash
|
|
88
|
+
path = entry["path"] or abort "Error: source entry missing 'path'"
|
|
89
|
+
Source.new(path: File.join(@base_dir, path), label: entry.fetch("label", File.basename(path)))
|
|
90
|
+
else
|
|
91
|
+
abort "Error: invalid source entry: #{entry.inspect}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
72
96
|
def parse_structure(entries)
|
|
73
97
|
entries.map do |entry|
|
|
74
98
|
case entry
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin Fiddle wrapper for Linux inotify(7).
|
|
4
|
+
# Non-Linux platforms will get LoadError from dlsym lookup.
|
|
5
|
+
|
|
6
|
+
require "fiddle"
|
|
7
|
+
|
|
8
|
+
module Ligarb
|
|
9
|
+
module Inotify
|
|
10
|
+
LIBC = Fiddle.dlopen(nil)
|
|
11
|
+
|
|
12
|
+
InotifyInit1 = Fiddle::Function.new(
|
|
13
|
+
LIBC["inotify_init1"],
|
|
14
|
+
[Fiddle::TYPE_INT],
|
|
15
|
+
Fiddle::TYPE_INT
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
InotifyAddWatch = Fiddle::Function.new(
|
|
19
|
+
LIBC["inotify_add_watch"],
|
|
20
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
|
|
21
|
+
Fiddle::TYPE_INT
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
InotifyRmWatch = Fiddle::Function.new(
|
|
25
|
+
LIBC["inotify_rm_watch"],
|
|
26
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT],
|
|
27
|
+
Fiddle::TYPE_INT
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# inotify event masks
|
|
31
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
32
|
+
IN_CLOEXEC = 0x00080000 # O_CLOEXEC on x86_64
|
|
33
|
+
|
|
34
|
+
# struct inotify_event fixed part: int wd, uint32 mask, uint32 cookie, uint32 len
|
|
35
|
+
EVENT_HEADER_SIZE = 16
|
|
36
|
+
|
|
37
|
+
# Watch a file for writes. Yields each time the file is written and closed.
|
|
38
|
+
# Blocks the calling thread. Caller should wrap in Thread.new.
|
|
39
|
+
def self.watch_file(path, &block)
|
|
40
|
+
fd = InotifyInit1.call(IN_CLOEXEC)
|
|
41
|
+
raise SystemCallError.new("inotify_init1", Fiddle.last_error) if fd < 0
|
|
42
|
+
|
|
43
|
+
io = IO.for_fd(fd, autoclose: true)
|
|
44
|
+
wd = add_watch(fd, path)
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
# IO.select releases GVL while waiting
|
|
48
|
+
IO.select([io])
|
|
49
|
+
buf = io.read_nonblock(4096, exception: false)
|
|
50
|
+
next if buf == :wait_readable || buf.nil?
|
|
51
|
+
|
|
52
|
+
# Parse events — may contain multiple events
|
|
53
|
+
offset = 0
|
|
54
|
+
while offset + EVENT_HEADER_SIZE <= buf.bytesize
|
|
55
|
+
_wd, mask, _cookie, name_len = buf.byteslice(offset, EVENT_HEADER_SIZE).unpack("iIII")
|
|
56
|
+
offset += EVENT_HEADER_SIZE + name_len
|
|
57
|
+
|
|
58
|
+
if mask & IN_CLOSE_WRITE != 0
|
|
59
|
+
block.call
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
ensure
|
|
66
|
+
io&.close rescue nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.add_watch(fd, path)
|
|
70
|
+
wd = InotifyAddWatch.call(fd, path, IN_CLOSE_WRITE)
|
|
71
|
+
raise SystemCallError.new("inotify_add_watch: #{path}", Fiddle.last_error) if wd < 0
|
|
72
|
+
wd
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Ligarb
|
|
8
|
+
class ReviewStore
|
|
9
|
+
def initialize(base_dir)
|
|
10
|
+
@dir = File.join(base_dir, ".ligarb", "reviews")
|
|
11
|
+
FileUtils.mkdir_p(@dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list
|
|
15
|
+
Dir.glob(File.join(@dir, "*.json")).map { |f| read_json(f) }
|
|
16
|
+
.compact
|
|
17
|
+
.sort_by { |r| r["created_at"] }
|
|
18
|
+
.map { |r| summary(r) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(id)
|
|
22
|
+
path = file_path(id)
|
|
23
|
+
return nil unless File.exist?(path)
|
|
24
|
+
read_json(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create(context:, message:)
|
|
28
|
+
id = SecureRandom.uuid
|
|
29
|
+
now = Time.now.utc.iso8601
|
|
30
|
+
|
|
31
|
+
review = {
|
|
32
|
+
"id" => id,
|
|
33
|
+
"status" => "open",
|
|
34
|
+
"created_at" => now,
|
|
35
|
+
"context" => context,
|
|
36
|
+
"messages" => [
|
|
37
|
+
{ "role" => "user", "content" => message, "timestamp" => now }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write_json(id, review)
|
|
42
|
+
review
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_message(id, role:, content:)
|
|
46
|
+
review = get(id)
|
|
47
|
+
return nil unless review
|
|
48
|
+
|
|
49
|
+
review["messages"] << {
|
|
50
|
+
"role" => role,
|
|
51
|
+
"content" => content,
|
|
52
|
+
"timestamp" => Time.now.utc.iso8601
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
write_json(id, review)
|
|
56
|
+
review
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_context_files(id, files)
|
|
60
|
+
review = get(id)
|
|
61
|
+
return nil unless review
|
|
62
|
+
|
|
63
|
+
existing = review.dig("context", "uploaded_files") || []
|
|
64
|
+
review["context"]["uploaded_files"] = existing + files
|
|
65
|
+
write_json(id, review)
|
|
66
|
+
review
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_status(id, status)
|
|
70
|
+
review = get(id)
|
|
71
|
+
return nil unless review
|
|
72
|
+
|
|
73
|
+
review["status"] = status
|
|
74
|
+
write_json(id, review)
|
|
75
|
+
review
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(id)
|
|
79
|
+
path = file_path(id)
|
|
80
|
+
return false unless File.exist?(path)
|
|
81
|
+
File.delete(path)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def file_path(id)
|
|
88
|
+
File.join(@dir, "#{id}.json")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_json(path)
|
|
92
|
+
JSON.parse(File.read(path))
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def write_json(id, data)
|
|
98
|
+
File.write(file_path(id), JSON.pretty_generate(data))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def summary(review)
|
|
102
|
+
{
|
|
103
|
+
"id" => review["id"],
|
|
104
|
+
"status" => review["status"],
|
|
105
|
+
"created_at" => review["created_at"],
|
|
106
|
+
"context" => review["context"],
|
|
107
|
+
"message_count" => review["messages"].size,
|
|
108
|
+
"last_message" => review["messages"].last
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|