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 +4 -4
- data/README.md +44 -13
- data/lib/bindery/book.rb +34 -1
- data/lib/bindery/builder.rb +71 -7
- data/lib/bindery/cli.rb +267 -33
- data/lib/bindery/cover.rb +135 -0
- data/lib/bindery/generators/book_generator.rb +20 -4
- data/lib/bindery/generators/chapter_generator.rb +49 -0
- data/lib/bindery/generators/project_generator.rb +16 -10
- data/lib/bindery/generators/templates/book/metadata.yaml.erb +5 -3
- data/lib/bindery/generators/templates/project/README.md.erb +29 -4
- data/lib/bindery/generators/templates/project/gitignore +0 -1
- data/lib/bindery/generators/volume_generator.rb +66 -0
- data/lib/bindery/image.rb +64 -0
- data/lib/bindery/project.rb +11 -1
- data/lib/bindery/server.rb +185 -0
- data/lib/bindery/validator.rb +52 -0
- data/lib/bindery/version.rb +1 -1
- data/lib/bindery.rb +6 -0
- metadata +12 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa243cc79c9ba7f641f57451435e100cbad86106f3a6d9fa497dedb777c23470
|
|
4
|
+
data.tar.gz: e4719f6f1b737b87a0370ce80a3d49724c29c2a4537ed70cdfd83e6ce6f31f1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
6
|
-
- `bindery
|
|
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
|
-
|
|
31
|
+
# 1. 创建并进入项目目录
|
|
32
|
+
mkdir my-books
|
|
32
33
|
cd my-books
|
|
33
34
|
|
|
34
|
-
# 2.
|
|
35
|
-
bindery
|
|
35
|
+
# 2. 就地初始化项目
|
|
36
|
+
bindery init
|
|
36
37
|
|
|
37
|
-
# 3.
|
|
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.
|
|
40
|
-
bindery
|
|
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.
|
|
58
|
-
│ └── chapters/ #
|
|
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? &&
|
|
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
|
data/lib/bindery/builder.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
# 支持:
|
|
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
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
58
|
+
Generators::ProjectGenerator.call
|
|
49
59
|
end
|
|
50
60
|
|
|
51
|
-
# ---------- bindery
|
|
52
|
-
def
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
75
|
-
if
|
|
76
|
-
warn "用法: bindery
|
|
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 %-
|
|
139
|
-
puts "-" *
|
|
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 %-
|
|
143
|
-
book.id, book.title, book.
|
|
144
|
-
book.
|
|
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 "-" *
|
|
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
|
|
157
|
-
bindery
|
|
158
|
-
|
|
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
|