bindery-cli 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af953a6d3a3f8b05cff050e4a9e7a6ced73a8bfc59a6c9269793b5f53e60cbde
4
- data.tar.gz: 877069de98701d6a504b010f3268cb1f2a1ce471fc4e5b56d3ea058ec769e35d
3
+ metadata.gz: aa243cc79c9ba7f641f57451435e100cbad86106f3a6d9fa497dedb777c23470
4
+ data.tar.gz: e4719f6f1b737b87a0370ce80a3d49724c29c2a4537ed70cdfd83e6ce6f31f1a
5
5
  SHA512:
6
- metadata.gz: 95295349a768fd7abb2c8c2a557391ae789b581265a07d299b9dfb617bcad57f6a902956fb53dc87dd7a8bc1bf480a390d4f28a08f0f07f4598cfb28d0f06cfc
7
- data.tar.gz: c05bfa81b2fa39164c2d0d21497b1cbebb1ae090fb95f6b87590796cd58a6542b9e69531849c62a2ac7989486082c3aaf201b0b17d93f63caf4c18d328079441
6
+ metadata.gz: a862c3a812471a5a85d183d7fe67a8dfc43c93861a58f52402eeb2e45bb530e11b07486ba2cc8dbb8d5847d3526d4ccc4d8a697c476f0bc1c7ba2bd174ed9f00
7
+ data.tar.gz: 319cd5177f67fb49fe759e2cbb077af355fc61c95307efd75e076b098866fb04782812c940354f9a2b643d56234cb05397b0be570c02369927c941d632b7f0c1
data/README.md CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  把 Markdown 公版书批量构建为 EPUB 的项目脚手架工具。
4
4
 
5
- - `bindery new <项目名>` 创建整套项目结构
6
- - `bindery generate book <id> <书名>` 为单本书生成骨架
5
+ - `bindery init` 在当前目录初始化项目
6
+ - `bindery new book <书名>` 新建一本书(ID 自动生成)
7
7
  - `bindery build` 调用 [Pandoc](https://pandoc.org) 批量构建 EPUB
8
+ - `bindery validate` / `clean` / `watch` / `serve` / `status` 管理书籍与产物
8
9
 
9
10
  仅依赖 Ruby 标准库,不引入任何第三方 gem。
10
11
 
@@ -27,23 +28,47 @@ brew install pandoc
27
28
  ## 快速开始
28
29
 
29
30
  ```bash
30
- # 1. 创建项目
31
- bindery new my-books
31
+ # 1. 创建并进入项目目录
32
+ mkdir my-books
32
33
  cd my-books
33
34
 
34
- # 2. 新建一本书
35
- bindery generate book lunyu "论语" --author "孔子及弟子" --dynasty "先秦"
35
+ # 2. 就地初始化项目
36
+ bindery init
36
37
 
37
- # 3. 编辑 books/lunyu/chapters/ 下的章节文件
38
+ # 3. 新建一本书(ID 自动生成,可 --id 指定;--cover 自动生成封面)
39
+ # 也可以一步到位建好章节:bindery new book "论语" chapter 20 --author "孔子及弟子"
40
+ # (N = 正文章节数,封面/目录由构建自动生成)
41
+ bindery new book "论语" --author "孔子及弟子" --dynasty "先秦" --translator "译者甲" --isbn "9781234567890" --cover
38
42
 
39
- # 4. 构建 EPUB
40
- bindery build lunyu
43
+ # 4. (可选)卷式结构:每个卷在 EPUB 中独立成页(h2=卷名,h3=章名)
44
+ bindery new volume <id> "学而" --chapters 5
45
+ bindery new volume <id> "为政" --chapters 5
41
46
 
42
- # 批量构建所有书
47
+ # 5. 编辑 books/<id>/chapters/ 下的章节文件
48
+ # (可选)单独批量创建章节:bindery new chapter <id> 10
49
+ # (可选)单独生成封面:bindery cover <id>
50
+
51
+ # 6. 构建 EPUB
43
52
  bindery build --all
44
53
 
54
+ # 构建前校验(不实际构建)
55
+ bindery validate --all
56
+ bindery build --all --check
57
+
58
+ # 构建后用 epubcheck 校验
59
+ bindery build --all --epubcheck
60
+
45
61
  # 查看书籍状态
46
62
  bindery status
63
+
64
+ # 监听书籍目录,改动自动重建
65
+ bindery watch
66
+
67
+ # 本地启动书库站点
68
+ bindery serve
69
+
70
+ # 清理构建产物
71
+ bindery clean --all
47
72
  ```
48
73
 
49
74
  ## 项目结构
@@ -51,11 +76,17 @@ bindery status
51
76
  ```
52
77
  my-books/
53
78
  ├── config/bindery.yml # 项目标记文件,不要删除
54
- ├── books/ # 每本书一个子目录
79
+ ├── books/ # 每本书一个子目录(ID 即目录名)
55
80
  │ └── <id>/
56
81
  │ ├── metadata.yaml # 书籍元信息
57
- │ ├── cover.jpg # 封面(可选)
58
- │ └── chapters/ # Markdown 章节,按文件名排序合并
82
+ │ ├── cover.png # 封面(可选,bindery cover 自动生成)
83
+ │ └── chapters/ # 平铺章节,或卷目录(每卷独立成页)
84
+ │ ├── 01-chapter.md
85
+ │ ├── 01-学而/
86
+ │ │ ├── 01.md
87
+ │ │ └── 02.md
88
+ │ └── 02-为政/
89
+ │ └── 01.md
59
90
  ├── templates/style.css # 全项目共用的 EPUB 样式
60
91
  ├── output/epub/ # 构建产物
61
92
  └── books.json # 自动生成的书目索引
data/lib/bindery/book.rb CHANGED
@@ -57,14 +57,38 @@ module Bindery
57
57
  dir + "chapters"
58
58
  end
59
59
 
60
+ # 平铺的章节文件(直接在 chapters/ 下,卷式书里通常为空)
60
61
  def chapters
61
62
  return [] unless chapters_dir.directory?
62
63
 
63
64
  chapters_dir.children.select { |f| f.file? && f.extname == ".md" }.sort
64
65
  end
65
66
 
67
+ # 卷目录(chapters/ 下的子目录,按文件名排序)
68
+ def volumes
69
+ return [] unless chapters_dir.directory?
70
+
71
+ chapters_dir.children.select(&:directory?).sort
72
+ end
73
+
74
+ def has_volumes?
75
+ !volumes.empty?
76
+ end
77
+
78
+ # 某个卷目录下的章节文件
79
+ def volume_chapters(vol_dir)
80
+ return [] unless vol_dir.directory?
81
+
82
+ vol_dir.children.select { |f| f.file? && f.extname == ".md" }.sort
83
+ end
84
+
85
+ # 所有章节(平铺 + 各卷内),供统计 / 校验使用
86
+ def all_chapters
87
+ (chapters + volumes.flat_map { |v| volume_chapters(v) }).sort_by(&:to_s)
88
+ end
89
+
66
90
  def valid?
67
- metadata_file.file? && chapters.any?
91
+ metadata_file.file? && all_chapters.any?
68
92
  end
69
93
 
70
94
  def epub_output(project_root = Project.root!)
@@ -74,5 +98,14 @@ module Bindery
74
98
  def built?(project_root = Project.root!)
75
99
  epub_output(project_root).file?
76
100
  end
101
+
102
+ def cover?
103
+ !cover_path.nil?
104
+ end
105
+
106
+ def built_at(project_root = Project.root!)
107
+ path = epub_output(project_root)
108
+ path.file? ? path.mtime : nil
109
+ end
77
110
  end
78
111
  end
@@ -9,15 +9,20 @@ module Bindery
9
9
  class Builder
10
10
  class BuildError < Bindery::Error; end
11
11
 
12
- def initialize(book, project_root: Project.root!)
12
+ def initialize(book, project_root: Project.root!, verbose: false)
13
13
  @book = book
14
14
  @project_root = project_root
15
+ @verbose = verbose
15
16
  end
16
17
 
17
18
  # 返回 true/false 表示是否成功;详细错误信息可通过 #last_error 获取
18
19
  def build_epub
19
- chapters = @book.chapters
20
- if chapters.empty?
20
+ if @book.has_volumes?
21
+ if @book.all_chapters.empty?
22
+ @last_error = "#{@book.id} 的卷目录下没有任何 .md 文件"
23
+ return false
24
+ end
25
+ elsif @book.chapters.empty?
21
26
  @last_error = "#{@book.id} 的 chapters/ 目录下没有任何 .md 文件"
22
27
  return false
23
28
  end
@@ -26,7 +31,14 @@ module Bindery
26
31
  ensure_pandoc!
27
32
 
28
33
  Dir.mktmpdir("bindery-#{@book.id}-") do |tmp|
29
- merged = merge_chapters(chapters, tmp)
34
+ if @book.has_volumes?
35
+ merged = merge_volumes(tmp)
36
+ split_level = 2
37
+ else
38
+ merged = merge_chapters(@book.chapters, tmp)
39
+ split_level = nil
40
+ end
41
+
30
42
  meta_file = write_pandoc_metadata(tmp, meta)
31
43
 
32
44
  out_dir = Project.output_dir(@project_root) + "epub"
@@ -35,6 +47,7 @@ module Bindery
35
47
 
36
48
  cmd = ["pandoc", merged.to_s, "-o", out_path.to_s,
37
49
  "--metadata-file", meta_file.to_s, "--toc"]
50
+ cmd += ["--split-level", split_level.to_s] if split_level
38
51
 
39
52
  css = Project.templates_dir(@project_root) + "style.css"
40
53
  cmd += ["--css", css.to_s] if css.file?
@@ -42,6 +55,7 @@ module Bindery
42
55
  cover = @book.cover_path
43
56
  cmd += ["--epub-cover-image", cover.to_s] if cover
44
57
 
58
+ puts " $ #{cmd.join(' ')}" if @verbose
45
59
  run(cmd)
46
60
  end
47
61
  rescue Bindery::Error => e
@@ -53,6 +67,16 @@ module Bindery
53
67
  @last_error
54
68
  end
55
69
 
70
+ # 运行 epubcheck 校验单个 epub,返回 [成功?, 输出]
71
+ def epubcheck(path)
72
+ unless system("epubcheck", "--version", out: File::NULL, err: File::NULL)
73
+ return [false, "未安装 epubcheck(参考 https://github.com/w3c/epubcheck/releases)"]
74
+ end
75
+
76
+ stdout, stderr, status = Open3.capture3("epubcheck", path.to_s)
77
+ [status.success?, stderr.empty? ? stdout : stderr]
78
+ end
79
+
56
80
  private
57
81
 
58
82
  def ensure_pandoc!
@@ -72,15 +96,55 @@ module Bindery
72
96
  path
73
97
  end
74
98
 
99
+ # 卷式书:每卷生成一个 ## 卷名(h2)页,卷内章节标题降级为 h3
100
+ def merge_volumes(tmp_dir)
101
+ path = Pathname.new(tmp_dir) + "merged.md"
102
+ path.open("w") do |out|
103
+ @book.volumes.each_with_index do |vol_dir, i|
104
+ out.puts if i.positive?
105
+ name = vol_dir.basename.to_s.sub(/\A\d+-/, "")
106
+ out.puts "## #{name}"
107
+ out.puts
108
+ @book.volume_chapters(vol_dir).each do |chapter|
109
+ out.puts demote_headings(chapter.read, 2)
110
+ out.puts
111
+ end
112
+ end
113
+ end
114
+ path
115
+ end
116
+
117
+ # 把 ATX 标题降级指定级数(跳过代码围栏内部)
118
+ def demote_headings(text, levels)
119
+ in_fence = false
120
+ text.lines.map do |line|
121
+ if line =~ /^\s*(`{3,}|~{3,})/
122
+ in_fence = !in_fence
123
+ line
124
+ elsif in_fence
125
+ line
126
+ elsif line =~ /^(\#{1,6})(\s|$)/
127
+ ("#" * levels) + line
128
+ else
129
+ line
130
+ end
131
+ end.join
132
+ end
133
+
75
134
  def write_pandoc_metadata(tmp_dir, meta)
135
+ config = Project.config(@project_root)
76
136
  pandoc_meta = {
77
137
  "title" => meta["title"] || @book.id,
78
138
  "author" => meta["author"] || "佚名",
79
- "lang" => meta["lang"] || "zh-CN",
80
- "rights" => meta["rights"] || "公共领域",
81
- "publisher" => meta["publisher"] || "再读经典",
139
+ "lang" => meta["lang"] || config["lang"] || "zh-CN",
140
+ "rights" => meta["rights"] || config["rights"] || "公共领域",
141
+ "publisher" => meta["publisher"] || config["publisher"] || "再读经典",
82
142
  }
83
143
  pandoc_meta["date"] = meta["dynasty"] if meta["dynasty"] && !meta["dynasty"].to_s.empty?
144
+ # 可选元数据:ISBN / 译者 / 分类,映射到 Pandoc 识别的字段
145
+ pandoc_meta["identifier"] = meta["isbn"] if meta["isbn"] && !meta["isbn"].to_s.empty?
146
+ pandoc_meta["contributor"] = meta["translator"] if meta["translator"] && !meta["translator"].to_s.empty?
147
+ pandoc_meta["subject"] = meta["category"] if meta["category"] && !meta["category"].to_s.empty?
84
148
 
85
149
  path = Pathname.new(tmp_dir) + "metadata.yaml"
86
150
  path.write(pandoc_meta.to_yaml)
data/lib/bindery/cli.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  require "optparse"
2
+ require "fileutils"
2
3
 
3
4
  module Bindery
4
5
  # 命令行入口:解析子命令并分发
5
- # 支持: new / generate(g) / build / status / version / help
6
+ # 支持: init / new book / build / clean / validate / watch / serve / status / version / help
6
7
  class CLI
7
8
  def self.start(argv)
8
9
  new.run(argv)
@@ -11,12 +12,22 @@ module Bindery
11
12
  def run(argv)
12
13
  command, *rest = argv
13
14
  case command
15
+ when "init"
16
+ cmd_init(rest)
14
17
  when "new"
15
18
  cmd_new(rest)
16
- when "generate", "g"
17
- cmd_generate(rest)
18
19
  when "build"
19
20
  cmd_build(rest)
21
+ when "clean"
22
+ cmd_clean(rest)
23
+ when "validate"
24
+ cmd_validate(rest)
25
+ when "cover"
26
+ cmd_cover(rest)
27
+ when "watch"
28
+ cmd_watch(rest)
29
+ when "serve"
30
+ cmd_serve(rest)
20
31
  when "status"
21
32
  cmd_status(rest)
22
33
  when "version", "-v", "--version"
@@ -38,74 +49,170 @@ module Bindery
38
49
 
39
50
  private
40
51
 
41
- # ---------- bindery new <项目名> ----------
42
- def cmd_new(args)
43
- name = args.first
44
- if name.nil? || name.empty?
45
- warn "用法: bindery new <项目名>"
52
+ # ---------- bindery init ----------
53
+ def cmd_init(args)
54
+ unless args.empty?
55
+ warn "用法: bindery init(不接受参数)"
46
56
  exit 1
47
57
  end
48
- Generators::ProjectGenerator.call(name)
58
+ Generators::ProjectGenerator.call
49
59
  end
50
60
 
51
- # ---------- bindery generate book <id> <title> [选项] ----------
52
- def cmd_generate(args)
61
+ # ---------- bindery new book|chapter|volume ... ----------
62
+ def cmd_new(args)
53
63
  type, *rest = args
54
64
  case type
55
65
  when "book"
56
- cmd_generate_book(rest)
66
+ cmd_new_book(rest)
67
+ when "chapter"
68
+ cmd_new_chapter(rest)
69
+ when "volume"
70
+ cmd_new_volume(rest)
57
71
  when nil
58
- warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
72
+ warn "用法: bindery new book <书名> [chapter <章数>] [选项]"
73
+ warn " bindery new chapter <book-id> <数量>"
74
+ warn " bindery new volume <book-id> <卷名> [--chapters N]"
59
75
  exit 1
60
76
  else
61
- warn "未知的生成器类型: #{type}(目前只支持 book)"
77
+ warn "未知的生成器类型: #{type}(支持 book / chapter / volume)"
78
+ exit 1
79
+ end
80
+ end
81
+
82
+ # ---------- bindery new chapter <book-id> <数量> ----------
83
+ def cmd_new_chapter(args)
84
+ book_id, count = args
85
+ if book_id.nil? || count.nil?
86
+ warn "用法: bindery new chapter <book-id> <数量>"
87
+ exit 1
88
+ end
89
+ Generators::ChapterGenerator.call(book_id, count)
90
+ end
91
+
92
+ # ---------- bindery new volume <book-id> <卷名> [--chapters N] ----------
93
+ def cmd_new_volume(args)
94
+ options = { chapters: 0 }
95
+ parser = OptionParser.new do |o|
96
+ o.on("--chapters N", Integer) { |v| options[:chapters] = v }
97
+ end
98
+ positional = parser.parse(args)
99
+ book_id, volume_name = positional
100
+ if book_id.nil? || volume_name.nil?
101
+ warn "用法: bindery new volume <book-id> <卷名> [--chapters N]"
102
+ exit 1
103
+ end
104
+ Generators::VolumeGenerator.call(book_id, volume_name, chapters: options[:chapters])
105
+ end
106
+
107
+ # ---------- bindery cover <book-id> ----------
108
+ def cmd_cover(args)
109
+ id = args.first
110
+ if id.nil?
111
+ warn "用法: bindery cover <book-id>"
62
112
  exit 1
63
113
  end
114
+ project_root = Project.root!
115
+ book = Bindery::Book.find(id, project_root)
116
+ path = Bindery::Cover.generate(book)
117
+ puts "已生成封面: #{path}"
64
118
  end
65
119
 
66
- def cmd_generate_book(args)
67
- options = { author: "佚名", dynasty: "", category: "" }
120
+ def cmd_new_book(args)
121
+ options = { author: "佚名", dynasty: "", category: "", translator: "", isbn: "", id: nil, cover: false }
68
122
  parser = OptionParser.new do |o|
69
123
  o.on("--author NAME") { |v| options[:author] = v }
70
124
  o.on("--dynasty NAME") { |v| options[:dynasty] = v }
71
125
  o.on("--category NAME") { |v| options[:category] = v }
126
+ o.on("--translator NAME") { |v| options[:translator] = v }
127
+ o.on("--isbn ISBN") { |v| options[:isbn] = v }
128
+ o.on("--id ID") { |v| options[:id] = v }
129
+ o.on("--cover") { options[:cover] = true }
72
130
  end
73
131
  positional = parser.parse(args)
74
- id, title = positional
75
- if id.nil? || title.nil?
76
- warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
132
+ title = positional.first
133
+ if title.nil? || title.empty?
134
+ warn "用法: bindery new book <书名> [chapter <章数>] [--cover] [--author NAME] [--dynasty 朝代] [--category 分类] [--translator 译者] [--isbn ISBN] [--id ID]"
77
135
  exit 1
78
136
  end
79
137
 
138
+ # 可选组合参数:bindery new book <书名> chapter <章数>
139
+ # 语义:总共 N 章(默认已含第一章,所以再补 N-1 章)
140
+ chapters = nil
141
+ unless positional[1].nil?
142
+ if positional[1] == "chapter"
143
+ chapters = positional[2].to_i
144
+ unless chapters.positive?
145
+ warn "章节数必须是正整数"
146
+ exit 1
147
+ end
148
+ else
149
+ warn "未知参数: #{positional[1]}"
150
+ exit 1
151
+ end
152
+ end
153
+
154
+ id = options[:id] || Generators::BookGenerator.auto_id(title)
80
155
  Generators::BookGenerator.call(
81
156
  id, title,
82
- author: options[:author], dynasty: options[:dynasty], category: options[:category]
157
+ author: options[:author], dynasty: options[:dynasty], category: options[:category],
158
+ translator: options[:translator], isbn: options[:isbn]
83
159
  )
160
+ Generators::ChapterGenerator.call(id, chapters - 1) if chapters && chapters > 1
161
+ Bindery::Cover.generate(Bindery::Book.find(id)) if options[:cover]
84
162
  end
85
163
 
86
- # ---------- bindery build [<id>] [--all] ----------
164
+ # ---------- bindery build [<id>] [--all] [--check] [--dry-run] [--verbose] ----------
87
165
  def cmd_build(args)
88
- options = { all: false }
166
+ options = { all: false, check: false, dry_run: false, verbose: false, epubcheck: false }
89
167
  parser = OptionParser.new do |o|
90
168
  o.on("--all") { options[:all] = true }
169
+ o.on("--check") { options[:check] = true }
170
+ o.on("--dry-run") { options[:dry_run] = true }
171
+ o.on("--verbose") { options[:verbose] = true }
172
+ o.on("--epubcheck") { options[:epubcheck] = true }
91
173
  end
92
174
  positional = parser.parse(args)
93
175
 
94
176
  project_root = Project.root!
95
177
  books = options[:all] ? Bindery::Book.all(project_root) : [book_from_arg(positional.first, project_root)]
96
178
 
179
+ if options[:check]
180
+ # 仅做构建前校验,不实际调用 Pandoc
181
+ exit 1 unless check_books(books, project_root)
182
+ return
183
+ end
184
+
97
185
  if books.empty?
98
186
  warn "books/ 目录下没有任何书籍"
187
+ # 即使没有书也重建索引,保证 books.json 存在(供 CI / 静态站读取)
188
+ Index.rebuild(project_root)
189
+ puts "" if options[:all]
190
+ puts "完成:成功 0 本,失败 0 本" if options[:all]
191
+ return
192
+ end
193
+
194
+ if options[:dry_run]
195
+ books.each do |book|
196
+ puts "[#{book.id}] 将构建 → #{book.epub_output(project_root)}(#{book.all_chapters.size} 个章节)"
197
+ end
99
198
  return
100
199
  end
101
200
 
102
201
  success = 0
103
202
  failure = 0
104
203
  books.each do |book|
105
- builder = Builder.new(book, project_root: project_root)
204
+ builder = Builder.new(book, project_root: project_root, verbose: options[:verbose])
106
205
  if builder.build_epub
107
206
  puts "✅ [#{book.id}] epub 构建成功"
108
207
  success += 1
208
+ if options[:epubcheck]
209
+ ok, msg = builder.epubcheck(book.epub_output(project_root))
210
+ if ok
211
+ puts " ✅ epubcheck 通过"
212
+ else
213
+ puts " ⚠️ epubcheck 未通过:#{msg.lines.first&.strip}"
214
+ end
215
+ end
109
216
  else
110
217
  puts "❌ [#{book.id}] epub 构建失败"
111
218
  puts " #{builder.last_error}"
@@ -126,6 +233,118 @@ module Bindery
126
233
  Bindery::Book.find(id, project_root)
127
234
  end
128
235
 
236
+ # ---------- bindery clean [--all] ----------
237
+ def cmd_clean(args)
238
+ options = { all: false }
239
+ parser = OptionParser.new do |o|
240
+ o.on("--all") { options[:all] = true }
241
+ end
242
+ parser.parse(args)
243
+
244
+ project_root = Project.root!
245
+ epub_dir = Project.output_dir(project_root) + "epub"
246
+ count = 0
247
+ if epub_dir.directory?
248
+ epub_dir.children.each do |f|
249
+ FileUtils.rm_rf(f)
250
+ count += 1
251
+ end
252
+ end
253
+
254
+ if options[:all]
255
+ index = Project.index_file(project_root)
256
+ if index.file?
257
+ index.delete
258
+ count += 1
259
+ end
260
+ end
261
+
262
+ puts "清理完成:移除 #{count} 项"
263
+ end
264
+
265
+ # ---------- bindery validate [<id>] [--all] ----------
266
+ def cmd_validate(args)
267
+ options = { all: false }
268
+ parser = OptionParser.new do |o|
269
+ o.on("--all") { options[:all] = true }
270
+ end
271
+ positional = parser.parse(args)
272
+
273
+ project_root = Project.root!
274
+ books = options[:all] ? Bindery::Book.all(project_root) : [book_from_arg(positional.first, project_root)]
275
+ exit 1 unless check_books(books, project_root)
276
+ end
277
+
278
+ # 校验并打印结果,返回是否全部通过(供 validate 与 build --check 共用)
279
+ def check_books(books, project_root)
280
+ return true if books.empty?
281
+
282
+ ok = true
283
+ books.each do |book|
284
+ issues = Bindery::Validator.check(book, project_root: project_root)
285
+ warns = Bindery::Validator.warnings(book, project_root: project_root)
286
+ if issues.empty?
287
+ puts "✅ [#{book.id}] 校验通过"
288
+ warns.each { |w| puts " ⚠️ #{w}" }
289
+ else
290
+ ok = false
291
+ puts "❌ [#{book.id}] 发现问题:"
292
+ issues.each { |i| puts " - #{i}" }
293
+ warns.each { |w| puts " ⚠️ #{w}" }
294
+ end
295
+ end
296
+ ok
297
+ end
298
+
299
+ # ---------- bindery watch [--interval SECONDS] ----------
300
+ def cmd_watch(args)
301
+ options = { interval: 2.0 }
302
+ parser = OptionParser.new do |o|
303
+ o.on("--interval SECONDS", Float) { |v| options[:interval] = v }
304
+ end
305
+ parser.parse(args)
306
+
307
+ project_root = Project.root!
308
+ puts "监听 books/ 变化,检测到改动会自动重建全部书籍(Ctrl-C 退出)..."
309
+ previous = books_fingerprint(project_root)
310
+ loop do
311
+ sleep options[:interval]
312
+ current = books_fingerprint(project_root)
313
+ next if current == previous
314
+
315
+ previous = current
316
+ puts "\n[#{Time.now.strftime('%H:%M:%S')}] 检测到变化,重新构建..."
317
+ cmd_build(["--all"])
318
+ end
319
+ rescue Interrupt
320
+ puts "\n已停止监听"
321
+ end
322
+
323
+ def books_fingerprint(project_root)
324
+ books_dir = Project.books_dir(project_root)
325
+ return nil unless books_dir.directory?
326
+
327
+ sig = []
328
+ Pathname.glob(books_dir + "**" + "*").each do |f|
329
+ sig << [f.to_s, f.mtime.to_f, f.size] if f.file?
330
+ end
331
+ sig.hash
332
+ end
333
+
334
+ # ---------- bindery serve [--port N] [--host H] ----------
335
+ def cmd_serve(args)
336
+ options = { port: 8000, host: "127.0.0.1" }
337
+ parser = OptionParser.new do |o|
338
+ o.on("--port N", Integer) { |v| options[:port] = v }
339
+ o.on("--host H") { |v| options[:host] = v }
340
+ end
341
+ parser.parse(args)
342
+
343
+ project_root = Project.root!
344
+ Index.rebuild(project_root) # 确保书库索引是最新的
345
+ Bindery::Server.new(project_root, host: options[:host], port: options[:port]).start
346
+ end
347
+
129
348
  # ---------- bindery status ----------
130
349
  def cmd_status(_args)
131
350
  project_root = Project.root!
@@ -135,16 +354,18 @@ module Bindery
135
354
  return
136
355
  end
137
356
 
138
- printf("%-16s %-16s %-8s %-6s\n", "书籍ID", "标题", "章节数", "EPUB")
139
- puts "-" * 50
357
+ printf("%-16s %-16s %-6s %-4s %-6s %-16s\n", "书籍ID", "标题", "章节数", "封面", "EPUB", "构建时间")
358
+ puts "-" * 66
140
359
  books.each do |book|
141
360
  printf(
142
- "%-16s %-16s %-8d %-6s\n",
143
- book.id, book.title, book.chapters.size,
144
- book.built?(project_root) ? "✅" : "—"
361
+ "%-16s %-16s %-6d %-4s %-6s %-16s\n",
362
+ book.id, book.title, book.all_chapters.size,
363
+ book.cover? ? "✅" : "—",
364
+ book.built?(project_root) ? "✅" : "—",
365
+ book.built_at(project_root)&.strftime("%m-%d %H:%M") || "—"
145
366
  )
146
367
  end
147
- puts "-" * 50
368
+ puts "-" * 66
148
369
  puts "共 #{books.size} 本书"
149
370
  end
150
371
 
@@ -153,12 +374,25 @@ module Bindery
153
374
  bindery #{Bindery::VERSION} — 再读经典项目脚手架工具
154
375
 
155
376
  用法:
156
- bindery new <项目名> 创建新项目
157
- bindery generate book <id> <书名> [选项] 为项目生成一本新书
158
- 简写: bindery g book <id> <书名>
159
- 选项: --author NAME --dynasty 朝代 --category 分类
377
+ bindery init 在当前目录初始化项目
378
+ bindery new book <书名> [chapter <章数>] [选项] 新建一本书(ID 自动生成)
379
+ 可组合: chapter <章数> 一并批量创建章节(N = 正文章节数,封面/目录不计入)
380
+ 选项: --id ID(可选) --author NAME --dynasty 朝代 --category 分类
381
+ --translator 译者 --isbn ISBN --cover 自动生成封面
382
+ bindery new chapter <book-id> <数量> 批量创建章节(编号自动接续)
383
+ bindery new volume <book-id> <卷名> [--chapters N] 新建卷(每卷在 EPUB 中独立成页)
384
+ bindery cover <book-id> 自动生成装饰性封面
160
385
  bindery build <id> 构建单本书的 epub
161
386
  bindery build --all 批量构建所有书
387
+ bindery build <id> --check 构建前校验(不实际构建)
388
+ bindery build <id> --dry-run 仅打印将构建的目标
389
+ bindery build <id> --verbose 打印 pandoc 命令
390
+ bindery build <id> --epubcheck 构建后用 epubcheck 校验
391
+ bindery validate <id> 校验单本书
392
+ bindery validate --all 校验所有书
393
+ bindery clean [--all] 清理构建产物(--all 连 books.json 一起删)
394
+ bindery watch [--interval 秒] 监听 books/ 自动重建
395
+ bindery serve [--port N] [--host H] 启动本地书库站点
162
396
  bindery status 查看所有书籍状态
163
397
  bindery version 查看版本号
164
398
  HELP