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.
data/lib/ligarb/cli.rb CHANGED
@@ -17,6 +17,27 @@ 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
27
+ when "write"
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
40
+ end
20
41
  when "--help", "-h", nil
21
42
  print_usage
22
43
  when "help"
@@ -37,6 +58,9 @@ module Ligarb
37
58
  Usage:
38
59
  ligarb init [DIRECTORY] Create a new book project
39
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
62
+ ligarb write [BRIEF] Generate a book with AI from brief.yml
63
+ ligarb write --init [DIR] Create DIR/brief.yml template
40
64
  ligarb help Show detailed specification (for AI integration)
41
65
  ligarb version Show version number
42
66
 
@@ -53,6 +77,8 @@ module Ligarb
53
77
  chapter_numbers (optional) Show chapter/section numbers (default: true)
54
78
  style (optional) Custom CSS file path (default: none)
55
79
  repository (optional) GitHub repository URL for "Edit on GitHub" links
80
+ ai_generated (optional) Mark as AI-generated (badge + meta tags, default: false)
81
+ footer (optional) Custom text at bottom of each chapter
56
82
 
57
83
  Example:
58
84
  ligarb build
@@ -60,8 +86,8 @@ module Ligarb
60
86
  USAGE
61
87
  end
62
88
 
63
- def print_spec
64
- puts <<~SPEC
89
+ def spec_text
90
+ <<~SPEC
65
91
  ligarb - Generate a single-page HTML book from Markdown files
66
92
 
67
93
  Version: #{VERSION}
@@ -95,6 +121,31 @@ module Ligarb
95
121
  ligarb build [CONFIG] Build the HTML book.
96
122
  CONFIG defaults to 'book.yml' in the current directory.
97
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
+
98
149
  ligarb help Show this detailed specification.
99
150
 
100
151
  ligarb --help Show short usage summary.
@@ -117,6 +168,14 @@ module Ligarb
117
168
  When set, each chapter shows a "View on GitHub" link.
118
169
  The link points to {repository}/blob/HEAD/{path-from-git-root}.
119
170
  The chapter path is resolved relative to the Git repository root.
171
+ ai_generated: (optional) Mark the book as AI-generated content. Default: false.
172
+ When true: adds an "AI Generated" badge in the sidebar header,
173
+ adds a default disclaimer footer to each chapter, and adds
174
+ noindex/noai meta tags to prevent search indexing and AI training.
175
+ The footer text can be overridden with the 'footer' field.
176
+ footer: (optional) Custom text displayed at the bottom of each chapter.
177
+ Overrides the default ai_generated disclaimer if both are set.
178
+ Useful for copyright notices, disclaimers, or other per-chapter text.
120
179
  chapters: (required) Book structure. An array that can contain:
121
180
  - A cover: a centered title/landing page
122
181
  - A string: a chapter Markdown file path (relative to book.yml)
@@ -262,6 +321,17 @@ module Ligarb
262
321
  E = mc^2
263
322
  ```
264
323
 
324
+ Inline math uses $...$ syntax within text:
325
+
326
+ The equation $E = mc^2$ is well-known.
327
+
328
+ Rules for inline math:
329
+ - $$ is not matched (use ```math for display math)
330
+ - $ followed by a space is not matched (e.g. $10)
331
+ - $ preceded by a space is not matched
332
+ - Content inside <code> and <pre> is not affected
333
+ - The content is rendered with KaTeX (displayMode: false)
334
+
265
335
  == Images ==
266
336
 
267
337
  Place image files in the 'images/' directory next to book.yml.
@@ -311,6 +381,67 @@ module Ligarb
311
381
 
312
382
  Clicking an index entry navigates to the exact location in the chapter.
313
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
+
314
445
  == Custom CSS ==
315
446
 
316
447
  Add a 'style' field to book.yml to inject custom CSS:
@@ -405,8 +536,43 @@ module Ligarb
405
536
 
406
537
  Each chapter displays Previous and Next navigation links at the bottom.
407
538
  These follow the flat chapter order (including across parts and appendix).
408
- Part title pages do not show navigation.
539
+ Cover pages do not show navigation.
540
+
541
+ == Write Command ==
542
+
543
+ ligarb write [BRIEF] Generate a complete book using AI (Claude).
544
+ BRIEF defaults to 'brief.yml' in the current directory.
545
+ Reads the brief, sends a prompt to Claude, and builds
546
+ the generated book. Files are created in the same
547
+ directory as brief.yml.
548
+
549
+ ligarb write --init [DIR] Create a brief.yml template.
550
+ If DIR is given, creates DIR/brief.yml (mkdir as needed).
551
+ If omitted, creates brief.yml in the current directory.
552
+
553
+ ligarb write --no-build Generate files only, skip the build step.
554
+
555
+ brief.yml fields:
556
+
557
+ title: (required) The book title.
558
+ language: (optional) Language. Default: "ja".
559
+ audience: (optional) Target audience (used in the prompt).
560
+ notes: (optional) Additional instructions for Claude (free text).
561
+ author: (optional) Passed through to book.yml.
562
+ output_dir: (optional) Passed through to book.yml.
563
+ chapter_numbers: (optional) Passed through to book.yml.
564
+ style: (optional) Passed through to book.yml.
565
+ repository: (optional) Passed through to book.yml.
566
+
567
+ The book is generated in the directory containing brief.yml.
568
+ Example: 'ligarb write ruby_book/brief.yml' creates files in ruby_book/.
569
+
570
+ Requires the 'claude' CLI to be installed.
409
571
  SPEC
410
572
  end
573
+
574
+ def print_spec
575
+ puts spec_text
576
+ end
411
577
  end
412
578
  end
data/lib/ligarb/config.rb CHANGED
@@ -13,7 +13,8 @@ module Ligarb
13
13
  # children: array of StructEntry (for :part and :appendix_group)
14
14
 
15
15
  attr_reader :title, :author, :language, :output_dir, :base_dir,
16
- :chapter_numbers, :structure, :style, :repository
16
+ :chapter_numbers, :structure, :style, :repository,
17
+ :ai_generated, :footer, :bibliography, :sources
17
18
 
18
19
  def initialize(path)
19
20
  @base_dir = File.dirname(File.expand_path(path))
@@ -28,6 +29,10 @@ module Ligarb
28
29
  @chapter_numbers = data.fetch("chapter_numbers", true)
29
30
  @style = data.fetch("style", nil)
30
31
  @repository = data.fetch("repository", nil)
32
+ @ai_generated = data.fetch("ai_generated", false)
33
+ @footer = data.fetch("footer", nil)
34
+ @bibliography = data.fetch("bibliography", nil)
35
+ @sources = parse_sources(data.fetch("sources", []))
31
36
  @structure = parse_structure(data["chapters"])
32
37
  end
33
38
 
@@ -39,10 +44,25 @@ module Ligarb
39
44
  @style ? File.join(@base_dir, @style) : nil
40
45
  end
41
46
 
47
+ def bibliography_path
48
+ @bibliography ? File.join(@base_dir, @bibliography) : nil
49
+ end
50
+
42
51
  def appendix_label
43
52
  @language == "ja" ? "付録" : "Appendix"
44
53
  end
45
54
 
55
+ def effective_footer
56
+ return @footer if @footer
57
+ return nil unless @ai_generated
58
+
59
+ if @language == "ja"
60
+ "この章の内容は AI によって生成されました。正確性は保証されません。"
61
+ else
62
+ "This chapter was generated by AI. Accuracy is not guaranteed."
63
+ end
64
+ end
65
+
46
66
  # Returns a flat list of all chapter file paths (excluding part title pages)
47
67
  def chapter_paths
48
68
  collect_chapter_paths(@structure)
@@ -55,6 +75,24 @@ module Ligarb
55
75
 
56
76
  private
57
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
+
58
96
  def parse_structure(entries)
59
97
  entries.map do |entry|
60
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