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.
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
- if args.delete("--init")
22
- require_relative "writer"
23
- Writer.init_brief(args.first)
24
- else
25
- brief_path = args.reject { |a| a.start_with?("--") }.first || "brief.yml"
26
- no_build = args.include?("--no-build")
27
- require_relative "writer"
28
- Writer.new(brief_path, no_build: no_build).run
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