bindery-cli 0.2.1 → 0.3.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/bindery/cli.rb CHANGED
@@ -4,7 +4,7 @@ require "rbconfig"
4
4
 
5
5
  module Bindery
6
6
  # 命令行入口:解析子命令并分发
7
- # 支持: init / new book / build / clean / validate / watch / serve / status / version / help
7
+ # 支持: init / new book / build / clean / validate / watch / serve / cover / status / version / help
8
8
  class CLI
9
9
  def self.start(argv)
10
10
  new.run(argv)
@@ -23,16 +23,14 @@ module Bindery
23
23
  cmd_clean(rest)
24
24
  when "validate"
25
25
  cmd_validate(rest)
26
- when "cover"
27
- cmd_cover(rest)
28
26
  when "rename"
29
27
  cmd_rename(rest)
30
28
  when "watch"
31
29
  cmd_watch(rest)
32
30
  when "serve"
33
31
  cmd_serve(rest)
34
- when "web"
35
- cmd_web(rest)
32
+ when "cover"
33
+ cmd_cover(rest)
36
34
  when "status"
37
35
  cmd_status(rest)
38
36
  when "version", "-v", "--version"
@@ -109,19 +107,6 @@ module Bindery
109
107
  Generators::VolumeGenerator.call(book_id, volume_name, chapters: options[:chapters])
110
108
  end
111
109
 
112
- # ---------- bindery cover <book-id> ----------
113
- def cmd_cover(args)
114
- id = args.first
115
- if id.nil?
116
- warn "用法: bindery cover <book-id>"
117
- exit 1
118
- end
119
- project_root = Project.root!
120
- book = Bindery::Book.find(id, project_root)
121
- path = Bindery::Cover.generate(book)
122
- puts "已生成封面: #{path}"
123
- end
124
-
125
110
  # ---------- bindery rename chapters <book-id> ----------
126
111
  def cmd_rename(args)
127
112
  type, book_id = args
@@ -351,7 +336,7 @@ module Bindery
351
336
 
352
337
  # ---------- bindery serve [--port N] [--host H] ----------
353
338
  def cmd_serve(args)
354
- options = { port: 8000, host: "127.0.0.1" }
339
+ options = { port: nil, host: "127.0.0.1" }
355
340
  parser = OptionParser.new do |o|
356
341
  o.on("--port N", Integer) { |v| options[:port] = v }
357
342
  o.on("--host H") { |v| options[:host] = v }
@@ -363,9 +348,9 @@ module Bindery
363
348
  Bindery::Server.new(project_root, host: options[:host], port: options[:port]).start
364
349
  end
365
350
 
366
- # ---------- bindery web [--port N] [--host H] ----------
367
- def cmd_web(args)
368
- options = { port: 8000, host: "127.0.0.1" }
351
+ # ---------- bindery cover [--port N] [--host H] ----------
352
+ def cmd_cover(args)
353
+ options = { port: nil, host: "127.0.0.1" }
369
354
  parser = OptionParser.new do |o|
370
355
  o.on("--port N", Integer) { |v| options[:port] = v }
371
356
  o.on("--host H") { |v| options[:host] = v }
@@ -375,8 +360,9 @@ module Bindery
375
360
  project_root = Project.root!
376
361
  Index.rebuild(project_root)
377
362
 
378
- url = "http://#{options[:host]}:#{options[:port]}/cover"
379
363
  server = Bindery::Server.new(project_root, host: options[:host], port: options[:port])
364
+ server.listen # 先绑定端口(未指定 --port 时由系统自动分配),再拿到实际端口拼 URL
365
+ url = "http://#{options[:host]}:#{server.port}/cover"
380
366
  thread = Thread.new do
381
367
  begin
382
368
  server.start
@@ -384,7 +370,6 @@ module Bindery
384
370
  warn "服务启动失败: #{e.message}"
385
371
  end
386
372
  end
387
- sleep 0.5 # 等服务就绪
388
373
  open_browser(url)
389
374
  puts "封面生成器已打开: #{url}(Ctrl-C 退出)"
390
375
  thread.join
@@ -439,7 +424,6 @@ module Bindery
439
424
  --translator 译者 --isbn ISBN --cover 自动生成封面
440
425
  bindery new chapter <book-id> <数量> 批量创建章节(编号自动接续)
441
426
  bindery new volume <book-id> <卷名> [--chapters N] 新建卷(平铺 md,h2=卷名/h3=章名)
442
- bindery cover <book-id> 自动生成装饰性封面
443
427
  bindery rename chapters <book-id> 按标题自动更新章节文件名
444
428
  bindery build <id> 构建单本书的 epub
445
429
  bindery build --all 批量构建所有书
@@ -451,8 +435,8 @@ module Bindery
451
435
  bindery validate --all 校验所有书
452
436
  bindery clean [--all] 清理构建产物(--all 连 books.json 一起删)
453
437
  bindery watch [--interval 秒] 监听 books/ 自动重建
454
- bindery serve [--port N] [--host H] 启动本地书库站点
455
- bindery web [--port N] 打开 Web 封面生成器
438
+ bindery serve [--port N] [--host H] 启动本地书库站点(不加 --port 时自动选空闲端口)
439
+ bindery cover [--port N] [--host H] 打开 Web 封面生成器(不加 --port 时自动选空闲端口)
456
440
  bindery status 查看所有书籍状态
457
441
  bindery version 查看版本号
458
442
  HELP
@@ -19,9 +19,6 @@ bindery new chapter <书籍ID> <数量>
19
19
  # 按标题自动更新章节文件名(chapter-0001-标题.md)
20
20
  bindery rename chapters <书籍ID>
21
21
 
22
- # 单独生成封面
23
- bindery cover <书籍ID>
24
-
25
22
  # 构建一本书
26
23
  bindery build <书籍ID>
27
24
 
@@ -45,7 +42,7 @@ bindery watch
45
42
  bindery serve
46
43
 
47
44
  # 打开 Web 封面生成器(自动填充书籍信息)
48
- bindery web
45
+ bindery cover
49
46
 
50
47
  # 清理构建产物
51
48
  bindery clean --all
@@ -20,20 +20,33 @@ module Bindery
20
20
  ".svg" => "image/svg+xml",
21
21
  }.freeze
22
22
 
23
- # 封面编辑器页面(随 gem 一起打包)
23
+ # 书库与封面编辑器页面(随 gem 一起打包)
24
24
  ASSETS_DIR = Pathname.new(__dir__) + "assets"
25
25
 
26
- def initialize(project_root, host: "127.0.0.1", port: 8000)
26
+ attr_reader :port
27
+
28
+ # port 为 nil 时不预先指定端口,由操作系统自动分配一个空闲端口
29
+ def initialize(project_root, host: "127.0.0.1", port: nil)
27
30
  @project_root = project_root
28
31
  @host = host
29
32
  @port = port
30
33
  end
31
34
 
35
+ # 绑定监听地址,并返回实际监听的端口号(未指定 --port 时可用于提前拿到自动分配的端口)
36
+ def listen
37
+ return @port if @server
38
+
39
+ @server = TCPServer.new(@host, @port || 0)
40
+ @port = @server.addr[1]
41
+ rescue Errno::EADDRINUSE
42
+ raise Bindery::Error, "端口 #{@port} 已被占用,换个端口试试(--port N),或不指定 --port 让系统自动分配"
43
+ end
44
+
32
45
  def start
33
- server = TCPServer.new(@host, @port)
46
+ listen
34
47
  puts "服务已启动: http://#{@host}:#{@port} (Ctrl-C 退出)"
35
48
  loop do
36
- client = server.accept
49
+ client = @server.accept
37
50
  begin
38
51
  respond(client)
39
52
  rescue StandardError => e
@@ -50,7 +63,7 @@ module Bindery
50
63
  puts "\n服务已停止"
51
64
  ensure
52
65
  begin
53
- server.close
66
+ @server&.close
54
67
  rescue StandardError
55
68
  nil
56
69
  end
@@ -72,7 +85,7 @@ module Bindery
72
85
  end
73
86
  end
74
87
 
75
- path = (URI::DEFAULT_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
88
+ path = (URI::RFC2396_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
76
89
  path = "/" if path.nil? || path.empty?
77
90
  path = path.gsub("..", "") # 防目录穿越
78
91
 
@@ -84,12 +97,11 @@ module Bindery
84
97
 
85
98
  case path
86
99
  when "/", "/index.html"
87
- body = index_html
88
- send_response(client, 200, MIME[".html"], body, method)
100
+ serve_file(client, ASSETS_DIR + "index.html", MIME[".html"], method)
89
101
  when "/books.json"
90
102
  serve_file(client, Project.index_file(@project_root), MIME[".json"], method)
91
103
  when "/cover"
92
- serve_file(client, ASSETS_DIR + "index.html", MIME[".html"], method)
104
+ serve_file(client, ASSETS_DIR + "cover.html", MIME[".html"], method)
93
105
  when %r{\A/epub/([\w\-.]+\.epub)\z}
94
106
  serve_file(client, Project.output_dir(@project_root) + "epub" + Regexp.last_match(1), MIME[".epub"], method)
95
107
  when %r{\A/cover/([\w\-]+)\z}
@@ -99,56 +111,6 @@ module Bindery
99
111
  end
100
112
  end
101
113
 
102
- def index_html
103
- index_file = Project.index_file(@project_root)
104
- data = if index_file.file?
105
- JSON.parse(index_file.read)
106
- else
107
- Bindery::Index.rebuild(@project_root)
108
- end
109
- books = data["books"] || []
110
-
111
- rows = books.map do |b|
112
- id = escape_html(b["id"].to_s)
113
- title = escape_html(b["title"].to_s)
114
- author = escape_html(b["author"].to_s)
115
- epub_link = if b["epub"]
116
- %(<a href="/epub/#{id}.epub" download>下载 EPUB</a>)
117
- else
118
- "<em>未构建</em>"
119
- end
120
- cover = %(<img src="/cover/#{id}" class="cover" alt="">)
121
- "<li class=\"book\">#{cover}<div class=\"info\"><h2>#{title}</h2><p class=\"author\">#{author}</p>#{epub_link}</div></li>"
122
- end.join("\n")
123
-
124
- <<~HTML
125
- <!doctype html>
126
- <html lang="zh-CN">
127
- <head>
128
- <meta charset="utf-8">
129
- <meta name="viewport" content="width=device-width, initial-scale=1">
130
- <title>#{escape_html(@project_root.basename.to_s)} · 书库</title>
131
- <style>
132
- body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
133
- h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: .4em; }
134
- ul { list-style: none; padding: 0; }
135
- .book { display: flex; gap: 1em; padding: 1em 0; border-bottom: 1px solid #f0f0f0; }
136
- .cover { width: 64px; height: 102px; object-fit: cover; background: #f5f5f5; border-radius: 4px; }
137
- .info h2 { margin: 0; font-size: 1.1em; }
138
- .author { color: #666; margin: .3em 0 .5em; }
139
- a { color: #b45309; }
140
- em { color: #999; }
141
- </style>
142
- </head>
143
- <body>
144
- <h1>#{escape_html(@project_root.basename.to_s)} · 书库</h1>
145
- <p>共 #{books.size} 本书</p>
146
- <ul>#{rows}</ul>
147
- </body>
148
- </html>
149
- HTML
150
- end
151
-
152
114
  def serve_cover(client, id, method)
153
115
  book_dir = Project.books_dir(@project_root) + id
154
116
  return not_found(client, method) unless book_dir.directory?
@@ -230,9 +192,5 @@ module Bindery
230
192
  client.write(headers)
231
193
  client.write(body) unless method == "HEAD"
232
194
  end
233
-
234
- def escape_html(str)
235
- str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
236
- end
237
195
  end
238
196
  end
@@ -1,3 +1,3 @@
1
1
  module Bindery
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bindery-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Billow Wang
@@ -24,6 +24,7 @@ files:
24
24
  - README.md
25
25
  - exe/bindery
26
26
  - lib/bindery.rb
27
+ - lib/bindery/assets/cover.html
27
28
  - lib/bindery/assets/index.html
28
29
  - lib/bindery/book.rb
29
30
  - lib/bindery/builder.rb